##// END OF EJS Templates
Optimize updates of issue's shared versions....
Jean-Philippe Lang -
r3023:0fe389b8417f
parent child
Show More
@@ -1,394 +1,411
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 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 Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 belongs_to :project
19 belongs_to :project
20 belongs_to :tracker
20 belongs_to :tracker
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27
27
28 has_many :journals, :as => :journalized, :dependent => :destroy
28 has_many :journals, :as => :journalized, :dependent => :destroy
29 has_many :time_entries, :dependent => :delete_all
29 has_many :time_entries, :dependent => :delete_all
30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
31
31
32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
34
34
35 acts_as_attachable :after_remove => :attachment_removed
35 acts_as_attachable :after_remove => :attachment_removed
36 acts_as_customizable
36 acts_as_customizable
37 acts_as_watchable
37 acts_as_watchable
38 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
38 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
39 :include => [:project, :journals],
39 :include => [:project, :journals],
40 # sort by id so that limited eager loading doesn't break with postgresql
40 # sort by id so that limited eager loading doesn't break with postgresql
41 :order_column => "#{table_name}.id"
41 :order_column => "#{table_name}.id"
42 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
42 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
43 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
43 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
44 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
44 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
45
45
46 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
46 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
47 :author_key => :author_id
47 :author_key => :author_id
48
48
49 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
49 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
50 validates_length_of :subject, :maximum => 255
50 validates_length_of :subject, :maximum => 255
51 validates_inclusion_of :done_ratio, :in => 0..100
51 validates_inclusion_of :done_ratio, :in => 0..100
52 validates_numericality_of :estimated_hours, :allow_nil => true
52 validates_numericality_of :estimated_hours, :allow_nil => true
53
53
54 named_scope :visible, lambda {|*args| { :include => :project,
54 named_scope :visible, lambda {|*args| { :include => :project,
55 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
55 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
56
56
57 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
57 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
58
58
59 after_save :create_journal
59 after_save :create_journal
60
60
61 # Returns true if usr or current user is allowed to view the issue
61 # Returns true if usr or current user is allowed to view the issue
62 def visible?(usr=nil)
62 def visible?(usr=nil)
63 (usr || User.current).allowed_to?(:view_issues, self.project)
63 (usr || User.current).allowed_to?(:view_issues, self.project)
64 end
64 end
65
65
66 def after_initialize
66 def after_initialize
67 if new_record?
67 if new_record?
68 # set default values for new records only
68 # set default values for new records only
69 self.status ||= IssueStatus.default
69 self.status ||= IssueStatus.default
70 self.priority ||= IssuePriority.default
70 self.priority ||= IssuePriority.default
71 end
71 end
72 end
72 end
73
73
74 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
74 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
75 def available_custom_fields
75 def available_custom_fields
76 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
76 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
77 end
77 end
78
78
79 def copy_from(arg)
79 def copy_from(arg)
80 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
80 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
81 self.attributes = issue.attributes.dup.except("id", "created_on", "updated_on")
81 self.attributes = issue.attributes.dup.except("id", "created_on", "updated_on")
82 self.custom_values = issue.custom_values.collect {|v| v.clone}
82 self.custom_values = issue.custom_values.collect {|v| v.clone}
83 self.status = issue.status
83 self.status = issue.status
84 self
84 self
85 end
85 end
86
86
87 # Moves/copies an issue to a new project and tracker
87 # Moves/copies an issue to a new project and tracker
88 # Returns the moved/copied issue on success, false on failure
88 # Returns the moved/copied issue on success, false on failure
89 def move_to(new_project, new_tracker = nil, options = {})
89 def move_to(new_project, new_tracker = nil, options = {})
90 options ||= {}
90 options ||= {}
91 issue = options[:copy] ? self.clone : self
91 issue = options[:copy] ? self.clone : self
92 transaction do
92 transaction do
93 if new_project && issue.project_id != new_project.id
93 if new_project && issue.project_id != new_project.id
94 # delete issue relations
94 # delete issue relations
95 unless Setting.cross_project_issue_relations?
95 unless Setting.cross_project_issue_relations?
96 issue.relations_from.clear
96 issue.relations_from.clear
97 issue.relations_to.clear
97 issue.relations_to.clear
98 end
98 end
99 # issue is moved to another project
99 # issue is moved to another project
100 # reassign to the category with same name if any
100 # reassign to the category with same name if any
101 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
101 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
102 issue.category = new_category
102 issue.category = new_category
103 # Keep the fixed_version if it's still valid in the new_project
103 # Keep the fixed_version if it's still valid in the new_project
104 unless new_project.shared_versions.include?(issue.fixed_version)
104 unless new_project.shared_versions.include?(issue.fixed_version)
105 issue.fixed_version = nil
105 issue.fixed_version = nil
106 end
106 end
107 issue.project = new_project
107 issue.project = new_project
108 end
108 end
109 if new_tracker
109 if new_tracker
110 issue.tracker = new_tracker
110 issue.tracker = new_tracker
111 end
111 end
112 if options[:copy]
112 if options[:copy]
113 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
113 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
114 issue.status = if options[:attributes] && options[:attributes][:status_id]
114 issue.status = if options[:attributes] && options[:attributes][:status_id]
115 IssueStatus.find_by_id(options[:attributes][:status_id])
115 IssueStatus.find_by_id(options[:attributes][:status_id])
116 else
116 else
117 self.status
117 self.status
118 end
118 end
119 end
119 end
120 # Allow bulk setting of attributes on the issue
120 # Allow bulk setting of attributes on the issue
121 if options[:attributes]
121 if options[:attributes]
122 issue.attributes = options[:attributes]
122 issue.attributes = options[:attributes]
123 end
123 end
124 if issue.save
124 if issue.save
125 unless options[:copy]
125 unless options[:copy]
126 # Manually update project_id on related time entries
126 # Manually update project_id on related time entries
127 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
127 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
128 end
128 end
129 else
129 else
130 Issue.connection.rollback_db_transaction
130 Issue.connection.rollback_db_transaction
131 return false
131 return false
132 end
132 end
133 end
133 end
134 return issue
134 return issue
135 end
135 end
136
136
137 def priority_id=(pid)
137 def priority_id=(pid)
138 self.priority = nil
138 self.priority = nil
139 write_attribute(:priority_id, pid)
139 write_attribute(:priority_id, pid)
140 end
140 end
141
141
142 def tracker_id=(tid)
142 def tracker_id=(tid)
143 self.tracker = nil
143 self.tracker = nil
144 write_attribute(:tracker_id, tid)
144 write_attribute(:tracker_id, tid)
145 end
145 end
146
146
147 def estimated_hours=(h)
147 def estimated_hours=(h)
148 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
148 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
149 end
149 end
150
150
151 def validate
151 def validate
152 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
152 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
153 errors.add :due_date, :not_a_date
153 errors.add :due_date, :not_a_date
154 end
154 end
155
155
156 if self.due_date and self.start_date and self.due_date < self.start_date
156 if self.due_date and self.start_date and self.due_date < self.start_date
157 errors.add :due_date, :greater_than_start_date
157 errors.add :due_date, :greater_than_start_date
158 end
158 end
159
159
160 if start_date && soonest_start && start_date < soonest_start
160 if start_date && soonest_start && start_date < soonest_start
161 errors.add :start_date, :invalid
161 errors.add :start_date, :invalid
162 end
162 end
163
163
164 if fixed_version
164 if fixed_version
165 if !assignable_versions.include?(fixed_version)
165 if !assignable_versions.include?(fixed_version)
166 errors.add :fixed_version_id, :inclusion
166 errors.add :fixed_version_id, :inclusion
167 elsif reopened? && fixed_version.closed?
167 elsif reopened? && fixed_version.closed?
168 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
168 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
169 end
169 end
170 end
170 end
171
171
172 # Checks that the issue can not be added/moved to a disabled tracker
172 # Checks that the issue can not be added/moved to a disabled tracker
173 if project && (tracker_id_changed? || project_id_changed?)
173 if project && (tracker_id_changed? || project_id_changed?)
174 unless project.trackers.include?(tracker)
174 unless project.trackers.include?(tracker)
175 errors.add :tracker_id, :inclusion
175 errors.add :tracker_id, :inclusion
176 end
176 end
177 end
177 end
178 end
178 end
179
179
180 def before_create
180 def before_create
181 # default assignment based on category
181 # default assignment based on category
182 if assigned_to.nil? && category && category.assigned_to
182 if assigned_to.nil? && category && category.assigned_to
183 self.assigned_to = category.assigned_to
183 self.assigned_to = category.assigned_to
184 end
184 end
185 end
185 end
186
186
187 def after_save
187 def after_save
188 # Reload is needed in order to get the right status
188 # Reload is needed in order to get the right status
189 reload
189 reload
190
190
191 # Update start/due dates of following issues
191 # Update start/due dates of following issues
192 relations_from.each(&:set_issue_to_dates)
192 relations_from.each(&:set_issue_to_dates)
193
193
194 # Close duplicates if the issue was closed
194 # Close duplicates if the issue was closed
195 if @issue_before_change && !@issue_before_change.closed? && self.closed?
195 if @issue_before_change && !@issue_before_change.closed? && self.closed?
196 duplicates.each do |duplicate|
196 duplicates.each do |duplicate|
197 # Reload is need in case the duplicate was updated by a previous duplicate
197 # Reload is need in case the duplicate was updated by a previous duplicate
198 duplicate.reload
198 duplicate.reload
199 # Don't re-close it if it's already closed
199 # Don't re-close it if it's already closed
200 next if duplicate.closed?
200 next if duplicate.closed?
201 # Same user and notes
201 # Same user and notes
202 duplicate.init_journal(@current_journal.user, @current_journal.notes)
202 duplicate.init_journal(@current_journal.user, @current_journal.notes)
203 duplicate.update_attribute :status, self.status
203 duplicate.update_attribute :status, self.status
204 end
204 end
205 end
205 end
206 end
206 end
207
207
208 def init_journal(user, notes = "")
208 def init_journal(user, notes = "")
209 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
209 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
210 @issue_before_change = self.clone
210 @issue_before_change = self.clone
211 @issue_before_change.status = self.status
211 @issue_before_change.status = self.status
212 @custom_values_before_change = {}
212 @custom_values_before_change = {}
213 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
213 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
214 # Make sure updated_on is updated when adding a note.
214 # Make sure updated_on is updated when adding a note.
215 updated_on_will_change!
215 updated_on_will_change!
216 @current_journal
216 @current_journal
217 end
217 end
218
218
219 # Return true if the issue is closed, otherwise false
219 # Return true if the issue is closed, otherwise false
220 def closed?
220 def closed?
221 self.status.is_closed?
221 self.status.is_closed?
222 end
222 end
223
223
224 # Return true if the issue is being reopened
224 # Return true if the issue is being reopened
225 def reopened?
225 def reopened?
226 if !new_record? && status_id_changed?
226 if !new_record? && status_id_changed?
227 status_was = IssueStatus.find_by_id(status_id_was)
227 status_was = IssueStatus.find_by_id(status_id_was)
228 status_new = IssueStatus.find_by_id(status_id)
228 status_new = IssueStatus.find_by_id(status_id)
229 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
229 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
230 return true
230 return true
231 end
231 end
232 end
232 end
233 false
233 false
234 end
234 end
235
235
236 # Returns true if the issue is overdue
236 # Returns true if the issue is overdue
237 def overdue?
237 def overdue?
238 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
238 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
239 end
239 end
240
240
241 # Users the issue can be assigned to
241 # Users the issue can be assigned to
242 def assignable_users
242 def assignable_users
243 project.assignable_users
243 project.assignable_users
244 end
244 end
245
245
246 # Versions that the issue can be assigned to
246 # Versions that the issue can be assigned to
247 def assignable_versions
247 def assignable_versions
248 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
248 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
249 end
249 end
250
250
251 # Returns true if this issue is blocked by another issue that is still open
251 # Returns true if this issue is blocked by another issue that is still open
252 def blocked?
252 def blocked?
253 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
253 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
254 end
254 end
255
255
256 # Returns an array of status that user is able to apply
256 # Returns an array of status that user is able to apply
257 def new_statuses_allowed_to(user)
257 def new_statuses_allowed_to(user)
258 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
258 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
259 statuses << status unless statuses.empty?
259 statuses << status unless statuses.empty?
260 statuses = statuses.uniq.sort
260 statuses = statuses.uniq.sort
261 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
261 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
262 end
262 end
263
263
264 # Returns the mail adresses of users that should be notified
264 # Returns the mail adresses of users that should be notified
265 def recipients
265 def recipients
266 notified = project.notified_users
266 notified = project.notified_users
267 # Author and assignee are always notified unless they have been locked
267 # Author and assignee are always notified unless they have been locked
268 notified << author if author && author.active?
268 notified << author if author && author.active?
269 notified << assigned_to if assigned_to && assigned_to.active?
269 notified << assigned_to if assigned_to && assigned_to.active?
270 notified.uniq!
270 notified.uniq!
271 # Remove users that can not view the issue
271 # Remove users that can not view the issue
272 notified.reject! {|user| !visible?(user)}
272 notified.reject! {|user| !visible?(user)}
273 notified.collect(&:mail)
273 notified.collect(&:mail)
274 end
274 end
275
275
276 # Returns the mail adresses of watchers that should be notified
276 # Returns the mail adresses of watchers that should be notified
277 def watcher_recipients
277 def watcher_recipients
278 notified = watcher_users
278 notified = watcher_users
279 notified.reject! {|user| !user.active? || !visible?(user)}
279 notified.reject! {|user| !user.active? || !visible?(user)}
280 notified.collect(&:mail)
280 notified.collect(&:mail)
281 end
281 end
282
282
283 # Returns the total number of hours spent on this issue.
283 # Returns the total number of hours spent on this issue.
284 #
284 #
285 # Example:
285 # Example:
286 # spent_hours => 0
286 # spent_hours => 0
287 # spent_hours => 50
287 # spent_hours => 50
288 def spent_hours
288 def spent_hours
289 @spent_hours ||= time_entries.sum(:hours) || 0
289 @spent_hours ||= time_entries.sum(:hours) || 0
290 end
290 end
291
291
292 def relations
292 def relations
293 (relations_from + relations_to).sort
293 (relations_from + relations_to).sort
294 end
294 end
295
295
296 def all_dependent_issues
296 def all_dependent_issues
297 dependencies = []
297 dependencies = []
298 relations_from.each do |relation|
298 relations_from.each do |relation|
299 dependencies << relation.issue_to
299 dependencies << relation.issue_to
300 dependencies += relation.issue_to.all_dependent_issues
300 dependencies += relation.issue_to.all_dependent_issues
301 end
301 end
302 dependencies
302 dependencies
303 end
303 end
304
304
305 # Returns an array of issues that duplicate this one
305 # Returns an array of issues that duplicate this one
306 def duplicates
306 def duplicates
307 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
307 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
308 end
308 end
309
309
310 # Returns the due date or the target due date if any
310 # Returns the due date or the target due date if any
311 # Used on gantt chart
311 # Used on gantt chart
312 def due_before
312 def due_before
313 due_date || (fixed_version ? fixed_version.effective_date : nil)
313 due_date || (fixed_version ? fixed_version.effective_date : nil)
314 end
314 end
315
315
316 # Returns the time scheduled for this issue.
316 # Returns the time scheduled for this issue.
317 #
317 #
318 # Example:
318 # Example:
319 # Start Date: 2/26/09, End Date: 3/04/09
319 # Start Date: 2/26/09, End Date: 3/04/09
320 # duration => 6
320 # duration => 6
321 def duration
321 def duration
322 (start_date && due_date) ? due_date - start_date : 0
322 (start_date && due_date) ? due_date - start_date : 0
323 end
323 end
324
324
325 def soonest_start
325 def soonest_start
326 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
326 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
327 end
327 end
328
328
329 def to_s
329 def to_s
330 "#{tracker} ##{id}: #{subject}"
330 "#{tracker} ##{id}: #{subject}"
331 end
331 end
332
332
333 # Returns a string of css classes that apply to the issue
333 # Returns a string of css classes that apply to the issue
334 def css_classes
334 def css_classes
335 s = "issue status-#{status.position} priority-#{priority.position}"
335 s = "issue status-#{status.position} priority-#{priority.position}"
336 s << ' closed' if closed?
336 s << ' closed' if closed?
337 s << ' overdue' if overdue?
337 s << ' overdue' if overdue?
338 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
338 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
339 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
339 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
340 s
340 s
341 end
341 end
342
342
343 # Update all issues so their versions are not pointing to a
343 # Unassigns issues from +version+ if it's no longer shared with issue's project
344 # fixed_version that is outside of the issue's project hierarchy.
344 def self.update_versions_from_sharing_change(version)
345 #
345 # Update issues assigned to the version
346 # OPTIMIZE: does a full table scan of Issues with a fixed_version.
346 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
347 def self.update_fixed_versions_from_sharing_change(conditions=nil)
347 end
348 Issue.all(:conditions => merge_conditions('fixed_version_id IS NOT NULL', conditions),
348
349 # Unassigns issues from versions that are no longer shared
350 # after +project+ was moved
351 def self.update_versions_from_hierarchy_change(project)
352 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
353 # Update issues of the moved projects and issues assigned to a version of a moved project
354 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
355 end
356
357 private
358
359 # Update issues so their versions are not pointing to a
360 # fixed_version that is not shared with the issue's project
361 def self.update_versions(conditions=nil)
362 # Only need to update issues with a fixed_version from
363 # a different project and that is not systemwide shared
364 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
365 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
366 " AND #{Version.table_name}.sharing <> 'system'",
367 conditions),
349 :include => [:project, :fixed_version]
368 :include => [:project, :fixed_version]
350 ).each do |issue|
369 ).each do |issue|
351 next if issue.project.nil? || issue.fixed_version.nil?
370 next if issue.project.nil? || issue.fixed_version.nil?
352 unless issue.project.shared_versions.include?(issue.fixed_version)
371 unless issue.project.shared_versions.include?(issue.fixed_version)
353 issue.init_journal(User.current)
372 issue.init_journal(User.current)
354 issue.fixed_version = nil
373 issue.fixed_version = nil
355 issue.save
374 issue.save
356 end
375 end
357 end
376 end
358 end
377 end
359
378
360 private
361
362 # Callback on attachment deletion
379 # Callback on attachment deletion
363 def attachment_removed(obj)
380 def attachment_removed(obj)
364 journal = init_journal(User.current)
381 journal = init_journal(User.current)
365 journal.details << JournalDetail.new(:property => 'attachment',
382 journal.details << JournalDetail.new(:property => 'attachment',
366 :prop_key => obj.id,
383 :prop_key => obj.id,
367 :old_value => obj.filename)
384 :old_value => obj.filename)
368 journal.save
385 journal.save
369 end
386 end
370
387
371 # Saves the changes in a Journal
388 # Saves the changes in a Journal
372 # Called after_save
389 # Called after_save
373 def create_journal
390 def create_journal
374 if @current_journal
391 if @current_journal
375 # attributes changes
392 # attributes changes
376 (Issue.column_names - %w(id description lock_version created_on updated_on)).each {|c|
393 (Issue.column_names - %w(id description lock_version created_on updated_on)).each {|c|
377 @current_journal.details << JournalDetail.new(:property => 'attr',
394 @current_journal.details << JournalDetail.new(:property => 'attr',
378 :prop_key => c,
395 :prop_key => c,
379 :old_value => @issue_before_change.send(c),
396 :old_value => @issue_before_change.send(c),
380 :value => send(c)) unless send(c)==@issue_before_change.send(c)
397 :value => send(c)) unless send(c)==@issue_before_change.send(c)
381 }
398 }
382 # custom fields changes
399 # custom fields changes
383 custom_values.each {|c|
400 custom_values.each {|c|
384 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
401 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
385 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
402 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
386 @current_journal.details << JournalDetail.new(:property => 'cf',
403 @current_journal.details << JournalDetail.new(:property => 'cf',
387 :prop_key => c.custom_field_id,
404 :prop_key => c.custom_field_id,
388 :old_value => @custom_values_before_change[c.custom_field_id],
405 :old_value => @custom_values_before_change[c.custom_field_id],
389 :value => c.value)
406 :value => c.value)
390 }
407 }
391 @current_journal.save
408 @current_journal.save
392 end
409 end
393 end
410 end
394 end
411 end
@@ -1,637 +1,637
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006 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 # Project statuses
19 # Project statuses
20 STATUS_ACTIVE = 1
20 STATUS_ACTIVE = 1
21 STATUS_ARCHIVED = 9
21 STATUS_ARCHIVED = 9
22
22
23 # Specific overidden Activities
23 # Specific overidden Activities
24 has_many :time_entry_activities
24 has_many :time_entry_activities
25 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
25 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
26 has_many :member_principals, :class_name => 'Member',
26 has_many :member_principals, :class_name => 'Member',
27 :include => :principal,
27 :include => :principal,
28 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
28 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
29 has_many :users, :through => :members
29 has_many :users, :through => :members
30 has_many :principals, :through => :member_principals, :source => :principal
30 has_many :principals, :through => :member_principals, :source => :principal
31
31
32 has_many :enabled_modules, :dependent => :delete_all
32 has_many :enabled_modules, :dependent => :delete_all
33 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
33 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
34 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
34 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
35 has_many :issue_changes, :through => :issues, :source => :journals
35 has_many :issue_changes, :through => :issues, :source => :journals
36 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
36 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
37 has_many :time_entries, :dependent => :delete_all
37 has_many :time_entries, :dependent => :delete_all
38 has_many :queries, :dependent => :delete_all
38 has_many :queries, :dependent => :delete_all
39 has_many :documents, :dependent => :destroy
39 has_many :documents, :dependent => :destroy
40 has_many :news, :dependent => :delete_all, :include => :author
40 has_many :news, :dependent => :delete_all, :include => :author
41 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
41 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
42 has_many :boards, :dependent => :destroy, :order => "position ASC"
42 has_many :boards, :dependent => :destroy, :order => "position ASC"
43 has_one :repository, :dependent => :destroy
43 has_one :repository, :dependent => :destroy
44 has_many :changesets, :through => :repository
44 has_many :changesets, :through => :repository
45 has_one :wiki, :dependent => :destroy
45 has_one :wiki, :dependent => :destroy
46 # Custom field for the project issues
46 # Custom field for the project issues
47 has_and_belongs_to_many :issue_custom_fields,
47 has_and_belongs_to_many :issue_custom_fields,
48 :class_name => 'IssueCustomField',
48 :class_name => 'IssueCustomField',
49 :order => "#{CustomField.table_name}.position",
49 :order => "#{CustomField.table_name}.position",
50 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
50 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
51 :association_foreign_key => 'custom_field_id'
51 :association_foreign_key => 'custom_field_id'
52
52
53 acts_as_nested_set :order => 'name', :dependent => :destroy
53 acts_as_nested_set :order => 'name', :dependent => :destroy
54 acts_as_attachable :view_permission => :view_files,
54 acts_as_attachable :view_permission => :view_files,
55 :delete_permission => :manage_files
55 :delete_permission => :manage_files
56
56
57 acts_as_customizable
57 acts_as_customizable
58 acts_as_searchable :columns => ['name', 'description'], :project_key => 'id', :permission => nil
58 acts_as_searchable :columns => ['name', 'description'], :project_key => 'id', :permission => nil
59 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
59 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
60 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o.id}},
60 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o.id}},
61 :author => nil
61 :author => nil
62
62
63 attr_protected :status, :enabled_module_names
63 attr_protected :status, :enabled_module_names
64
64
65 validates_presence_of :name, :identifier
65 validates_presence_of :name, :identifier
66 validates_uniqueness_of :name, :identifier
66 validates_uniqueness_of :name, :identifier
67 validates_associated :repository, :wiki
67 validates_associated :repository, :wiki
68 validates_length_of :name, :maximum => 30
68 validates_length_of :name, :maximum => 30
69 validates_length_of :homepage, :maximum => 255
69 validates_length_of :homepage, :maximum => 255
70 validates_length_of :identifier, :in => 1..20
70 validates_length_of :identifier, :in => 1..20
71 # donwcase letters, digits, dashes but not digits only
71 # donwcase letters, digits, dashes but not digits only
72 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :if => Proc.new { |p| p.identifier_changed? }
72 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :if => Proc.new { |p| p.identifier_changed? }
73 # reserved words
73 # reserved words
74 validates_exclusion_of :identifier, :in => %w( new )
74 validates_exclusion_of :identifier, :in => %w( new )
75
75
76 before_destroy :delete_all_members
76 before_destroy :delete_all_members
77
77
78 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
78 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
79 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
79 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
80 named_scope :all_public, { :conditions => { :is_public => true } }
80 named_scope :all_public, { :conditions => { :is_public => true } }
81 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
81 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
82
82
83 def identifier=(identifier)
83 def identifier=(identifier)
84 super unless identifier_frozen?
84 super unless identifier_frozen?
85 end
85 end
86
86
87 def identifier_frozen?
87 def identifier_frozen?
88 errors[:identifier].nil? && !(new_record? || identifier.blank?)
88 errors[:identifier].nil? && !(new_record? || identifier.blank?)
89 end
89 end
90
90
91 # returns latest created projects
91 # returns latest created projects
92 # non public projects will be returned only if user is a member of those
92 # non public projects will be returned only if user is a member of those
93 def self.latest(user=nil, count=5)
93 def self.latest(user=nil, count=5)
94 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
94 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
95 end
95 end
96
96
97 # Returns a SQL :conditions string used to find all active projects for the specified user.
97 # Returns a SQL :conditions string used to find all active projects for the specified user.
98 #
98 #
99 # Examples:
99 # Examples:
100 # Projects.visible_by(admin) => "projects.status = 1"
100 # Projects.visible_by(admin) => "projects.status = 1"
101 # Projects.visible_by(normal_user) => "projects.status = 1 AND projects.is_public = 1"
101 # Projects.visible_by(normal_user) => "projects.status = 1 AND projects.is_public = 1"
102 def self.visible_by(user=nil)
102 def self.visible_by(user=nil)
103 user ||= User.current
103 user ||= User.current
104 if user && user.admin?
104 if user && user.admin?
105 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
105 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
106 elsif user && user.memberships.any?
106 elsif user && user.memberships.any?
107 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND (#{Project.table_name}.is_public = #{connection.quoted_true} or #{Project.table_name}.id IN (#{user.memberships.collect{|m| m.project_id}.join(',')}))"
107 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND (#{Project.table_name}.is_public = #{connection.quoted_true} or #{Project.table_name}.id IN (#{user.memberships.collect{|m| m.project_id}.join(',')}))"
108 else
108 else
109 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
109 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
110 end
110 end
111 end
111 end
112
112
113 def self.allowed_to_condition(user, permission, options={})
113 def self.allowed_to_condition(user, permission, options={})
114 statements = []
114 statements = []
115 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
115 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
116 if perm = Redmine::AccessControl.permission(permission)
116 if perm = Redmine::AccessControl.permission(permission)
117 unless perm.project_module.nil?
117 unless perm.project_module.nil?
118 # If the permission belongs to a project module, make sure the module is enabled
118 # If the permission belongs to a project module, make sure the module is enabled
119 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
119 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
120 end
120 end
121 end
121 end
122 if options[:project]
122 if options[:project]
123 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
123 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
124 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
124 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
125 base_statement = "(#{project_statement}) AND (#{base_statement})"
125 base_statement = "(#{project_statement}) AND (#{base_statement})"
126 end
126 end
127 if user.admin?
127 if user.admin?
128 # no restriction
128 # no restriction
129 else
129 else
130 statements << "1=0"
130 statements << "1=0"
131 if user.logged?
131 if user.logged?
132 if Role.non_member.allowed_to?(permission) && !options[:member]
132 if Role.non_member.allowed_to?(permission) && !options[:member]
133 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
133 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
134 end
134 end
135 allowed_project_ids = user.memberships.select {|m| m.roles.detect {|role| role.allowed_to?(permission)}}.collect {|m| m.project_id}
135 allowed_project_ids = user.memberships.select {|m| m.roles.detect {|role| role.allowed_to?(permission)}}.collect {|m| m.project_id}
136 statements << "#{Project.table_name}.id IN (#{allowed_project_ids.join(',')})" if allowed_project_ids.any?
136 statements << "#{Project.table_name}.id IN (#{allowed_project_ids.join(',')})" if allowed_project_ids.any?
137 else
137 else
138 if Role.anonymous.allowed_to?(permission) && !options[:member]
138 if Role.anonymous.allowed_to?(permission) && !options[:member]
139 # anonymous user allowed on public project
139 # anonymous user allowed on public project
140 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
140 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
141 end
141 end
142 end
142 end
143 end
143 end
144 statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
144 statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
145 end
145 end
146
146
147 # Returns the Systemwide and project specific activities
147 # Returns the Systemwide and project specific activities
148 def activities(include_inactive=false)
148 def activities(include_inactive=false)
149 if include_inactive
149 if include_inactive
150 return all_activities
150 return all_activities
151 else
151 else
152 return active_activities
152 return active_activities
153 end
153 end
154 end
154 end
155
155
156 # Will create a new Project specific Activity or update an existing one
156 # Will create a new Project specific Activity or update an existing one
157 #
157 #
158 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
158 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
159 # does not successfully save.
159 # does not successfully save.
160 def update_or_create_time_entry_activity(id, activity_hash)
160 def update_or_create_time_entry_activity(id, activity_hash)
161 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
161 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
162 self.create_time_entry_activity_if_needed(activity_hash)
162 self.create_time_entry_activity_if_needed(activity_hash)
163 else
163 else
164 activity = project.time_entry_activities.find_by_id(id.to_i)
164 activity = project.time_entry_activities.find_by_id(id.to_i)
165 activity.update_attributes(activity_hash) if activity
165 activity.update_attributes(activity_hash) if activity
166 end
166 end
167 end
167 end
168
168
169 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
169 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
170 #
170 #
171 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
171 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
172 # does not successfully save.
172 # does not successfully save.
173 def create_time_entry_activity_if_needed(activity)
173 def create_time_entry_activity_if_needed(activity)
174 if activity['parent_id']
174 if activity['parent_id']
175
175
176 parent_activity = TimeEntryActivity.find(activity['parent_id'])
176 parent_activity = TimeEntryActivity.find(activity['parent_id'])
177 activity['name'] = parent_activity.name
177 activity['name'] = parent_activity.name
178 activity['position'] = parent_activity.position
178 activity['position'] = parent_activity.position
179
179
180 if Enumeration.overridding_change?(activity, parent_activity)
180 if Enumeration.overridding_change?(activity, parent_activity)
181 project_activity = self.time_entry_activities.create(activity)
181 project_activity = self.time_entry_activities.create(activity)
182
182
183 if project_activity.new_record?
183 if project_activity.new_record?
184 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
184 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
185 else
185 else
186 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
186 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
187 end
187 end
188 end
188 end
189 end
189 end
190 end
190 end
191
191
192 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
192 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
193 #
193 #
194 # Examples:
194 # Examples:
195 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
195 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
196 # project.project_condition(false) => "projects.id = 1"
196 # project.project_condition(false) => "projects.id = 1"
197 def project_condition(with_subprojects)
197 def project_condition(with_subprojects)
198 cond = "#{Project.table_name}.id = #{id}"
198 cond = "#{Project.table_name}.id = #{id}"
199 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
199 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
200 cond
200 cond
201 end
201 end
202
202
203 def self.find(*args)
203 def self.find(*args)
204 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
204 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
205 project = find_by_identifier(*args)
205 project = find_by_identifier(*args)
206 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
206 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
207 project
207 project
208 else
208 else
209 super
209 super
210 end
210 end
211 end
211 end
212
212
213 def to_param
213 def to_param
214 # id is used for projects with a numeric identifier (compatibility)
214 # id is used for projects with a numeric identifier (compatibility)
215 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
215 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
216 end
216 end
217
217
218 def active?
218 def active?
219 self.status == STATUS_ACTIVE
219 self.status == STATUS_ACTIVE
220 end
220 end
221
221
222 # Archives the project and its descendants
222 # Archives the project and its descendants
223 def archive
223 def archive
224 # Check that there is no issue of a non descendant project that is assigned
224 # Check that there is no issue of a non descendant project that is assigned
225 # to one of the project or descendant versions
225 # to one of the project or descendant versions
226 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
226 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
227 if v_ids.any? && Issue.find(:first, :include => :project,
227 if v_ids.any? && Issue.find(:first, :include => :project,
228 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
228 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
229 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
229 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
230 return false
230 return false
231 end
231 end
232 Project.transaction do
232 Project.transaction do
233 archive!
233 archive!
234 end
234 end
235 true
235 true
236 end
236 end
237
237
238 # Unarchives the project
238 # Unarchives the project
239 # All its ancestors must be active
239 # All its ancestors must be active
240 def unarchive
240 def unarchive
241 return false if ancestors.detect {|a| !a.active?}
241 return false if ancestors.detect {|a| !a.active?}
242 update_attribute :status, STATUS_ACTIVE
242 update_attribute :status, STATUS_ACTIVE
243 end
243 end
244
244
245 # Returns an array of projects the project can be moved to
245 # Returns an array of projects the project can be moved to
246 # by the current user
246 # by the current user
247 def allowed_parents
247 def allowed_parents
248 return @allowed_parents if @allowed_parents
248 return @allowed_parents if @allowed_parents
249 @allowed_parents = (Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_project, :member => true)) - self_and_descendants)
249 @allowed_parents = (Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_project, :member => true)) - self_and_descendants)
250 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
250 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
251 @allowed_parents << parent
251 @allowed_parents << parent
252 end
252 end
253 @allowed_parents
253 @allowed_parents
254 end
254 end
255
255
256 # Sets the parent of the project with authorization check
256 # Sets the parent of the project with authorization check
257 def set_allowed_parent!(p)
257 def set_allowed_parent!(p)
258 unless p.nil? || p.is_a?(Project)
258 unless p.nil? || p.is_a?(Project)
259 if p.to_s.blank?
259 if p.to_s.blank?
260 p = nil
260 p = nil
261 else
261 else
262 p = Project.find_by_id(p)
262 p = Project.find_by_id(p)
263 return false unless p
263 return false unless p
264 end
264 end
265 end
265 end
266 if p.nil?
266 if p.nil?
267 if !new_record? && allowed_parents.empty?
267 if !new_record? && allowed_parents.empty?
268 return false
268 return false
269 end
269 end
270 elsif !allowed_parents.include?(p)
270 elsif !allowed_parents.include?(p)
271 return false
271 return false
272 end
272 end
273 set_parent!(p)
273 set_parent!(p)
274 end
274 end
275
275
276 # Sets the parent of the project
276 # Sets the parent of the project
277 # Argument can be either a Project, a String, a Fixnum or nil
277 # Argument can be either a Project, a String, a Fixnum or nil
278 def set_parent!(p)
278 def set_parent!(p)
279 unless p.nil? || p.is_a?(Project)
279 unless p.nil? || p.is_a?(Project)
280 if p.to_s.blank?
280 if p.to_s.blank?
281 p = nil
281 p = nil
282 else
282 else
283 p = Project.find_by_id(p)
283 p = Project.find_by_id(p)
284 return false unless p
284 return false unless p
285 end
285 end
286 end
286 end
287 if p == parent && !p.nil?
287 if p == parent && !p.nil?
288 # Nothing to do
288 # Nothing to do
289 true
289 true
290 elsif p.nil? || (p.active? && move_possible?(p))
290 elsif p.nil? || (p.active? && move_possible?(p))
291 # Insert the project so that target's children or root projects stay alphabetically sorted
291 # Insert the project so that target's children or root projects stay alphabetically sorted
292 sibs = (p.nil? ? self.class.roots : p.children)
292 sibs = (p.nil? ? self.class.roots : p.children)
293 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
293 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
294 if to_be_inserted_before
294 if to_be_inserted_before
295 move_to_left_of(to_be_inserted_before)
295 move_to_left_of(to_be_inserted_before)
296 elsif p.nil?
296 elsif p.nil?
297 if sibs.empty?
297 if sibs.empty?
298 # move_to_root adds the project in first (ie. left) position
298 # move_to_root adds the project in first (ie. left) position
299 move_to_root
299 move_to_root
300 else
300 else
301 move_to_right_of(sibs.last) unless self == sibs.last
301 move_to_right_of(sibs.last) unless self == sibs.last
302 end
302 end
303 else
303 else
304 # move_to_child_of adds the project in last (ie.right) position
304 # move_to_child_of adds the project in last (ie.right) position
305 move_to_child_of(p)
305 move_to_child_of(p)
306 end
306 end
307 Issue.update_fixed_versions_from_sharing_change
307 Issue.update_versions_from_hierarchy_change(self)
308 true
308 true
309 else
309 else
310 # Can not move to the given target
310 # Can not move to the given target
311 false
311 false
312 end
312 end
313 end
313 end
314
314
315 # Returns an array of the trackers used by the project and its active sub projects
315 # Returns an array of the trackers used by the project and its active sub projects
316 def rolled_up_trackers
316 def rolled_up_trackers
317 @rolled_up_trackers ||=
317 @rolled_up_trackers ||=
318 Tracker.find(:all, :include => :projects,
318 Tracker.find(:all, :include => :projects,
319 :select => "DISTINCT #{Tracker.table_name}.*",
319 :select => "DISTINCT #{Tracker.table_name}.*",
320 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
320 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
321 :order => "#{Tracker.table_name}.position")
321 :order => "#{Tracker.table_name}.position")
322 end
322 end
323
323
324 # Closes open and locked project versions that are completed
324 # Closes open and locked project versions that are completed
325 def close_completed_versions
325 def close_completed_versions
326 Version.transaction do
326 Version.transaction do
327 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
327 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
328 if version.completed?
328 if version.completed?
329 version.update_attribute(:status, 'closed')
329 version.update_attribute(:status, 'closed')
330 end
330 end
331 end
331 end
332 end
332 end
333 end
333 end
334
334
335 # Returns a scope of the Versions used by the project
335 # Returns a scope of the Versions used by the project
336 def shared_versions
336 def shared_versions
337 @shared_versions ||=
337 @shared_versions ||=
338 Version.scoped(:include => :project,
338 Version.scoped(:include => :project,
339 :conditions => "#{Project.table_name}.id = #{id}" +
339 :conditions => "#{Project.table_name}.id = #{id}" +
340 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
340 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
341 " #{Version.table_name}.sharing = 'system'" +
341 " #{Version.table_name}.sharing = 'system'" +
342 " OR (#{Project.table_name}.lft >= #{root.lft} AND #{Project.table_name}.rgt <= #{root.rgt} AND #{Version.table_name}.sharing = 'tree')" +
342 " OR (#{Project.table_name}.lft >= #{root.lft} AND #{Project.table_name}.rgt <= #{root.rgt} AND #{Version.table_name}.sharing = 'tree')" +
343 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
343 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
344 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
344 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
345 "))")
345 "))")
346 end
346 end
347
347
348 # Returns a hash of project users grouped by role
348 # Returns a hash of project users grouped by role
349 def users_by_role
349 def users_by_role
350 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
350 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
351 m.roles.each do |r|
351 m.roles.each do |r|
352 h[r] ||= []
352 h[r] ||= []
353 h[r] << m.user
353 h[r] << m.user
354 end
354 end
355 h
355 h
356 end
356 end
357 end
357 end
358
358
359 # Deletes all project's members
359 # Deletes all project's members
360 def delete_all_members
360 def delete_all_members
361 me, mr = Member.table_name, MemberRole.table_name
361 me, mr = Member.table_name, MemberRole.table_name
362 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
362 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
363 Member.delete_all(['project_id = ?', id])
363 Member.delete_all(['project_id = ?', id])
364 end
364 end
365
365
366 # Users issues can be assigned to
366 # Users issues can be assigned to
367 def assignable_users
367 def assignable_users
368 members.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.user}.sort
368 members.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.user}.sort
369 end
369 end
370
370
371 # Returns the mail adresses of users that should be always notified on project events
371 # Returns the mail adresses of users that should be always notified on project events
372 def recipients
372 def recipients
373 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user.mail}
373 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user.mail}
374 end
374 end
375
375
376 # Returns the users that should be notified on project events
376 # Returns the users that should be notified on project events
377 def notified_users
377 def notified_users
378 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user}
378 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user}
379 end
379 end
380
380
381 # Returns an array of all custom fields enabled for project issues
381 # Returns an array of all custom fields enabled for project issues
382 # (explictly associated custom fields and custom fields enabled for all projects)
382 # (explictly associated custom fields and custom fields enabled for all projects)
383 def all_issue_custom_fields
383 def all_issue_custom_fields
384 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
384 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
385 end
385 end
386
386
387 def project
387 def project
388 self
388 self
389 end
389 end
390
390
391 def <=>(project)
391 def <=>(project)
392 name.downcase <=> project.name.downcase
392 name.downcase <=> project.name.downcase
393 end
393 end
394
394
395 def to_s
395 def to_s
396 name
396 name
397 end
397 end
398
398
399 # Returns a short description of the projects (first lines)
399 # Returns a short description of the projects (first lines)
400 def short_description(length = 255)
400 def short_description(length = 255)
401 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
401 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
402 end
402 end
403
403
404 # Return true if this project is allowed to do the specified action.
404 # Return true if this project is allowed to do the specified action.
405 # action can be:
405 # action can be:
406 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
406 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
407 # * a permission Symbol (eg. :edit_project)
407 # * a permission Symbol (eg. :edit_project)
408 def allows_to?(action)
408 def allows_to?(action)
409 if action.is_a? Hash
409 if action.is_a? Hash
410 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
410 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
411 else
411 else
412 allowed_permissions.include? action
412 allowed_permissions.include? action
413 end
413 end
414 end
414 end
415
415
416 def module_enabled?(module_name)
416 def module_enabled?(module_name)
417 module_name = module_name.to_s
417 module_name = module_name.to_s
418 enabled_modules.detect {|m| m.name == module_name}
418 enabled_modules.detect {|m| m.name == module_name}
419 end
419 end
420
420
421 def enabled_module_names=(module_names)
421 def enabled_module_names=(module_names)
422 if module_names && module_names.is_a?(Array)
422 if module_names && module_names.is_a?(Array)
423 module_names = module_names.collect(&:to_s)
423 module_names = module_names.collect(&:to_s)
424 # remove disabled modules
424 # remove disabled modules
425 enabled_modules.each {|mod| mod.destroy unless module_names.include?(mod.name)}
425 enabled_modules.each {|mod| mod.destroy unless module_names.include?(mod.name)}
426 # add new modules
426 # add new modules
427 module_names.reject {|name| module_enabled?(name)}.each {|name| enabled_modules << EnabledModule.new(:name => name)}
427 module_names.reject {|name| module_enabled?(name)}.each {|name| enabled_modules << EnabledModule.new(:name => name)}
428 else
428 else
429 enabled_modules.clear
429 enabled_modules.clear
430 end
430 end
431 end
431 end
432
432
433 # Returns an auto-generated project identifier based on the last identifier used
433 # Returns an auto-generated project identifier based on the last identifier used
434 def self.next_identifier
434 def self.next_identifier
435 p = Project.find(:first, :order => 'created_on DESC')
435 p = Project.find(:first, :order => 'created_on DESC')
436 p.nil? ? nil : p.identifier.to_s.succ
436 p.nil? ? nil : p.identifier.to_s.succ
437 end
437 end
438
438
439 # Copies and saves the Project instance based on the +project+.
439 # Copies and saves the Project instance based on the +project+.
440 # Duplicates the source project's:
440 # Duplicates the source project's:
441 # * Wiki
441 # * Wiki
442 # * Versions
442 # * Versions
443 # * Categories
443 # * Categories
444 # * Issues
444 # * Issues
445 # * Members
445 # * Members
446 # * Queries
446 # * Queries
447 #
447 #
448 # Accepts an +options+ argument to specify what to copy
448 # Accepts an +options+ argument to specify what to copy
449 #
449 #
450 # Examples:
450 # Examples:
451 # project.copy(1) # => copies everything
451 # project.copy(1) # => copies everything
452 # project.copy(1, :only => 'members') # => copies members only
452 # project.copy(1, :only => 'members') # => copies members only
453 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
453 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
454 def copy(project, options={})
454 def copy(project, options={})
455 project = project.is_a?(Project) ? project : Project.find(project)
455 project = project.is_a?(Project) ? project : Project.find(project)
456
456
457 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
457 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
458 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
458 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
459
459
460 Project.transaction do
460 Project.transaction do
461 if save
461 if save
462 reload
462 reload
463 to_be_copied.each do |name|
463 to_be_copied.each do |name|
464 send "copy_#{name}", project
464 send "copy_#{name}", project
465 end
465 end
466 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
466 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
467 save
467 save
468 end
468 end
469 end
469 end
470 end
470 end
471
471
472
472
473 # Copies +project+ and returns the new instance. This will not save
473 # Copies +project+ and returns the new instance. This will not save
474 # the copy
474 # the copy
475 def self.copy_from(project)
475 def self.copy_from(project)
476 begin
476 begin
477 project = project.is_a?(Project) ? project : Project.find(project)
477 project = project.is_a?(Project) ? project : Project.find(project)
478 if project
478 if project
479 # clear unique attributes
479 # clear unique attributes
480 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
480 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
481 copy = Project.new(attributes)
481 copy = Project.new(attributes)
482 copy.enabled_modules = project.enabled_modules
482 copy.enabled_modules = project.enabled_modules
483 copy.trackers = project.trackers
483 copy.trackers = project.trackers
484 copy.custom_values = project.custom_values.collect {|v| v.clone}
484 copy.custom_values = project.custom_values.collect {|v| v.clone}
485 copy.issue_custom_fields = project.issue_custom_fields
485 copy.issue_custom_fields = project.issue_custom_fields
486 return copy
486 return copy
487 else
487 else
488 return nil
488 return nil
489 end
489 end
490 rescue ActiveRecord::RecordNotFound
490 rescue ActiveRecord::RecordNotFound
491 return nil
491 return nil
492 end
492 end
493 end
493 end
494
494
495 private
495 private
496
496
497 # Copies wiki from +project+
497 # Copies wiki from +project+
498 def copy_wiki(project)
498 def copy_wiki(project)
499 # Check that the source project has a wiki first
499 # Check that the source project has a wiki first
500 unless project.wiki.nil?
500 unless project.wiki.nil?
501 self.wiki ||= Wiki.new
501 self.wiki ||= Wiki.new
502 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
502 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
503 project.wiki.pages.each do |page|
503 project.wiki.pages.each do |page|
504 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
504 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
505 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
505 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
506 new_wiki_page.content = new_wiki_content
506 new_wiki_page.content = new_wiki_content
507 wiki.pages << new_wiki_page
507 wiki.pages << new_wiki_page
508 end
508 end
509 end
509 end
510 end
510 end
511
511
512 # Copies versions from +project+
512 # Copies versions from +project+
513 def copy_versions(project)
513 def copy_versions(project)
514 project.versions.each do |version|
514 project.versions.each do |version|
515 new_version = Version.new
515 new_version = Version.new
516 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
516 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
517 self.versions << new_version
517 self.versions << new_version
518 end
518 end
519 end
519 end
520
520
521 # Copies issue categories from +project+
521 # Copies issue categories from +project+
522 def copy_issue_categories(project)
522 def copy_issue_categories(project)
523 project.issue_categories.each do |issue_category|
523 project.issue_categories.each do |issue_category|
524 new_issue_category = IssueCategory.new
524 new_issue_category = IssueCategory.new
525 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
525 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
526 self.issue_categories << new_issue_category
526 self.issue_categories << new_issue_category
527 end
527 end
528 end
528 end
529
529
530 # Copies issues from +project+
530 # Copies issues from +project+
531 def copy_issues(project)
531 def copy_issues(project)
532 project.issues.each do |issue|
532 project.issues.each do |issue|
533 new_issue = Issue.new
533 new_issue = Issue.new
534 new_issue.copy_from(issue)
534 new_issue.copy_from(issue)
535 # Reassign fixed_versions by name, since names are unique per
535 # Reassign fixed_versions by name, since names are unique per
536 # project and the versions for self are not yet saved
536 # project and the versions for self are not yet saved
537 if issue.fixed_version
537 if issue.fixed_version
538 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
538 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
539 end
539 end
540 # Reassign the category by name, since names are unique per
540 # Reassign the category by name, since names are unique per
541 # project and the categories for self are not yet saved
541 # project and the categories for self are not yet saved
542 if issue.category
542 if issue.category
543 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
543 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
544 end
544 end
545 self.issues << new_issue
545 self.issues << new_issue
546 end
546 end
547 end
547 end
548
548
549 # Copies members from +project+
549 # Copies members from +project+
550 def copy_members(project)
550 def copy_members(project)
551 project.members.each do |member|
551 project.members.each do |member|
552 new_member = Member.new
552 new_member = Member.new
553 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
553 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
554 new_member.role_ids = member.role_ids.dup
554 new_member.role_ids = member.role_ids.dup
555 new_member.project = self
555 new_member.project = self
556 self.members << new_member
556 self.members << new_member
557 end
557 end
558 end
558 end
559
559
560 # Copies queries from +project+
560 # Copies queries from +project+
561 def copy_queries(project)
561 def copy_queries(project)
562 project.queries.each do |query|
562 project.queries.each do |query|
563 new_query = Query.new
563 new_query = Query.new
564 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
564 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
565 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
565 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
566 new_query.project = self
566 new_query.project = self
567 self.queries << new_query
567 self.queries << new_query
568 end
568 end
569 end
569 end
570
570
571 # Copies boards from +project+
571 # Copies boards from +project+
572 def copy_boards(project)
572 def copy_boards(project)
573 project.boards.each do |board|
573 project.boards.each do |board|
574 new_board = Board.new
574 new_board = Board.new
575 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
575 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
576 new_board.project = self
576 new_board.project = self
577 self.boards << new_board
577 self.boards << new_board
578 end
578 end
579 end
579 end
580
580
581 def allowed_permissions
581 def allowed_permissions
582 @allowed_permissions ||= begin
582 @allowed_permissions ||= begin
583 module_names = enabled_modules.collect {|m| m.name}
583 module_names = enabled_modules.collect {|m| m.name}
584 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
584 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
585 end
585 end
586 end
586 end
587
587
588 def allowed_actions
588 def allowed_actions
589 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
589 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
590 end
590 end
591
591
592 # Returns all the active Systemwide and project specific activities
592 # Returns all the active Systemwide and project specific activities
593 def active_activities
593 def active_activities
594 overridden_activity_ids = self.time_entry_activities.active.collect(&:parent_id)
594 overridden_activity_ids = self.time_entry_activities.active.collect(&:parent_id)
595
595
596 if overridden_activity_ids.empty?
596 if overridden_activity_ids.empty?
597 return TimeEntryActivity.shared.active
597 return TimeEntryActivity.shared.active
598 else
598 else
599 return system_activities_and_project_overrides
599 return system_activities_and_project_overrides
600 end
600 end
601 end
601 end
602
602
603 # Returns all the Systemwide and project specific activities
603 # Returns all the Systemwide and project specific activities
604 # (inactive and active)
604 # (inactive and active)
605 def all_activities
605 def all_activities
606 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
606 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
607
607
608 if overridden_activity_ids.empty?
608 if overridden_activity_ids.empty?
609 return TimeEntryActivity.shared
609 return TimeEntryActivity.shared
610 else
610 else
611 return system_activities_and_project_overrides(true)
611 return system_activities_and_project_overrides(true)
612 end
612 end
613 end
613 end
614
614
615 # Returns the systemwide active activities merged with the project specific overrides
615 # Returns the systemwide active activities merged with the project specific overrides
616 def system_activities_and_project_overrides(include_inactive=false)
616 def system_activities_and_project_overrides(include_inactive=false)
617 if include_inactive
617 if include_inactive
618 return TimeEntryActivity.shared.
618 return TimeEntryActivity.shared.
619 find(:all,
619 find(:all,
620 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
620 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
621 self.time_entry_activities
621 self.time_entry_activities
622 else
622 else
623 return TimeEntryActivity.shared.active.
623 return TimeEntryActivity.shared.active.
624 find(:all,
624 find(:all,
625 :conditions => ["id NOT IN (?)", self.time_entry_activities.active.collect(&:parent_id)]) +
625 :conditions => ["id NOT IN (?)", self.time_entry_activities.active.collect(&:parent_id)]) +
626 self.time_entry_activities.active
626 self.time_entry_activities.active
627 end
627 end
628 end
628 end
629
629
630 # Archives subprojects recursively
630 # Archives subprojects recursively
631 def archive!
631 def archive!
632 children.each do |subproject|
632 children.each do |subproject|
633 subproject.send :archive!
633 subproject.send :archive!
634 end
634 end
635 update_attribute :status, STATUS_ARCHIVED
635 update_attribute :status, STATUS_ARCHIVED
636 end
636 end
637 end
637 end
@@ -1,209 +1,209
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006 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 Version < ActiveRecord::Base
18 class Version < ActiveRecord::Base
19 before_destroy :check_integrity
19 before_destroy :check_integrity
20 after_update :update_issues_from_sharing_change
20 after_update :update_issues_from_sharing_change
21 belongs_to :project
21 belongs_to :project
22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id'
22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id'
23 acts_as_customizable
23 acts_as_customizable
24 acts_as_attachable :view_permission => :view_files,
24 acts_as_attachable :view_permission => :view_files,
25 :delete_permission => :manage_files
25 :delete_permission => :manage_files
26
26
27 VERSION_STATUSES = %w(open locked closed)
27 VERSION_STATUSES = %w(open locked closed)
28 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
28 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
29
29
30 validates_presence_of :name
30 validates_presence_of :name
31 validates_uniqueness_of :name, :scope => [:project_id]
31 validates_uniqueness_of :name, :scope => [:project_id]
32 validates_length_of :name, :maximum => 60
32 validates_length_of :name, :maximum => 60
33 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
33 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
34 validates_inclusion_of :status, :in => VERSION_STATUSES
34 validates_inclusion_of :status, :in => VERSION_STATUSES
35 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
35 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
36
36
37 named_scope :open, :conditions => {:status => 'open'}
37 named_scope :open, :conditions => {:status => 'open'}
38 named_scope :visible, lambda {|*args| { :include => :project,
38 named_scope :visible, lambda {|*args| { :include => :project,
39 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
39 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
40
40
41 # Returns true if +user+ or current user is allowed to view the version
41 # Returns true if +user+ or current user is allowed to view the version
42 def visible?(user=User.current)
42 def visible?(user=User.current)
43 user.allowed_to?(:view_issues, self.project)
43 user.allowed_to?(:view_issues, self.project)
44 end
44 end
45
45
46 def start_date
46 def start_date
47 effective_date
47 effective_date
48 end
48 end
49
49
50 def due_date
50 def due_date
51 effective_date
51 effective_date
52 end
52 end
53
53
54 # Returns the total estimated time for this version
54 # Returns the total estimated time for this version
55 def estimated_hours
55 def estimated_hours
56 @estimated_hours ||= fixed_issues.sum(:estimated_hours).to_f
56 @estimated_hours ||= fixed_issues.sum(:estimated_hours).to_f
57 end
57 end
58
58
59 # Returns the total reported time for this version
59 # Returns the total reported time for this version
60 def spent_hours
60 def spent_hours
61 @spent_hours ||= TimeEntry.sum(:hours, :include => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
61 @spent_hours ||= TimeEntry.sum(:hours, :include => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
62 end
62 end
63
63
64 def closed?
64 def closed?
65 status == 'closed'
65 status == 'closed'
66 end
66 end
67
67
68 def open?
68 def open?
69 status == 'open'
69 status == 'open'
70 end
70 end
71
71
72 # Returns true if the version is completed: due date reached and no open issues
72 # Returns true if the version is completed: due date reached and no open issues
73 def completed?
73 def completed?
74 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
74 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
75 end
75 end
76
76
77 # Returns the completion percentage of this version based on the amount of open/closed issues
77 # Returns the completion percentage of this version based on the amount of open/closed issues
78 # and the time spent on the open issues.
78 # and the time spent on the open issues.
79 def completed_pourcent
79 def completed_pourcent
80 if issues_count == 0
80 if issues_count == 0
81 0
81 0
82 elsif open_issues_count == 0
82 elsif open_issues_count == 0
83 100
83 100
84 else
84 else
85 issues_progress(false) + issues_progress(true)
85 issues_progress(false) + issues_progress(true)
86 end
86 end
87 end
87 end
88
88
89 # Returns the percentage of issues that have been marked as 'closed'.
89 # Returns the percentage of issues that have been marked as 'closed'.
90 def closed_pourcent
90 def closed_pourcent
91 if issues_count == 0
91 if issues_count == 0
92 0
92 0
93 else
93 else
94 issues_progress(false)
94 issues_progress(false)
95 end
95 end
96 end
96 end
97
97
98 # Returns true if the version is overdue: due date reached and some open issues
98 # Returns true if the version is overdue: due date reached and some open issues
99 def overdue?
99 def overdue?
100 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
100 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
101 end
101 end
102
102
103 # Returns assigned issues count
103 # Returns assigned issues count
104 def issues_count
104 def issues_count
105 @issue_count ||= fixed_issues.count
105 @issue_count ||= fixed_issues.count
106 end
106 end
107
107
108 # Returns the total amount of open issues for this version.
108 # Returns the total amount of open issues for this version.
109 def open_issues_count
109 def open_issues_count
110 @open_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, false], :include => :status)
110 @open_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, false], :include => :status)
111 end
111 end
112
112
113 # Returns the total amount of closed issues for this version.
113 # Returns the total amount of closed issues for this version.
114 def closed_issues_count
114 def closed_issues_count
115 @closed_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, true], :include => :status)
115 @closed_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, true], :include => :status)
116 end
116 end
117
117
118 def wiki_page
118 def wiki_page
119 if project.wiki && !wiki_page_title.blank?
119 if project.wiki && !wiki_page_title.blank?
120 @wiki_page ||= project.wiki.find_page(wiki_page_title)
120 @wiki_page ||= project.wiki.find_page(wiki_page_title)
121 end
121 end
122 @wiki_page
122 @wiki_page
123 end
123 end
124
124
125 def to_s; name end
125 def to_s; name end
126
126
127 # Versions are sorted by effective_date and name
127 # Versions are sorted by effective_date and name
128 # Those with no effective_date are at the end, sorted by name
128 # Those with no effective_date are at the end, sorted by name
129 def <=>(version)
129 def <=>(version)
130 if self.effective_date
130 if self.effective_date
131 version.effective_date ? (self.effective_date == version.effective_date ? self.name <=> version.name : self.effective_date <=> version.effective_date) : -1
131 version.effective_date ? (self.effective_date == version.effective_date ? self.name <=> version.name : self.effective_date <=> version.effective_date) : -1
132 else
132 else
133 version.effective_date ? 1 : (self.name <=> version.name)
133 version.effective_date ? 1 : (self.name <=> version.name)
134 end
134 end
135 end
135 end
136
136
137 # Returns the sharings that +user+ can set the version to
137 # Returns the sharings that +user+ can set the version to
138 def allowed_sharings(user = User.current)
138 def allowed_sharings(user = User.current)
139 VERSION_SHARINGS.select do |s|
139 VERSION_SHARINGS.select do |s|
140 if sharing == s
140 if sharing == s
141 true
141 true
142 else
142 else
143 case s
143 case s
144 when 'system'
144 when 'system'
145 # Only admin users can set a systemwide sharing
145 # Only admin users can set a systemwide sharing
146 user.admin?
146 user.admin?
147 when 'hierarchy', 'tree'
147 when 'hierarchy', 'tree'
148 # Only users allowed to manage versions of the root project can
148 # Only users allowed to manage versions of the root project can
149 # set sharing to hierarchy or tree
149 # set sharing to hierarchy or tree
150 project.nil? || user.allowed_to?(:manage_versions, project.root)
150 project.nil? || user.allowed_to?(:manage_versions, project.root)
151 else
151 else
152 true
152 true
153 end
153 end
154 end
154 end
155 end
155 end
156 end
156 end
157
157
158 private
158 private
159 def check_integrity
159 def check_integrity
160 raise "Can't delete version" if self.fixed_issues.find(:first)
160 raise "Can't delete version" if self.fixed_issues.find(:first)
161 end
161 end
162
162
163 # Update the issue's fixed versions. Used if a version's sharing changes.
163 # Update the issue's fixed versions. Used if a version's sharing changes.
164 def update_issues_from_sharing_change
164 def update_issues_from_sharing_change
165 if sharing_changed?
165 if sharing_changed?
166 if VERSION_SHARINGS.index(sharing_was).nil? ||
166 if VERSION_SHARINGS.index(sharing_was).nil? ||
167 VERSION_SHARINGS.index(sharing).nil? ||
167 VERSION_SHARINGS.index(sharing).nil? ||
168 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
168 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
169 Issue.update_fixed_versions_from_sharing_change ["fixed_version_id = ? AND #{Issue.table_name}.project_id <> ?", id, project_id]
169 Issue.update_versions_from_sharing_change self
170 end
170 end
171 end
171 end
172 end
172 end
173
173
174 # Returns the average estimated time of assigned issues
174 # Returns the average estimated time of assigned issues
175 # or 1 if no issue has an estimated time
175 # or 1 if no issue has an estimated time
176 # Used to weigth unestimated issues in progress calculation
176 # Used to weigth unestimated issues in progress calculation
177 def estimated_average
177 def estimated_average
178 if @estimated_average.nil?
178 if @estimated_average.nil?
179 average = fixed_issues.average(:estimated_hours).to_f
179 average = fixed_issues.average(:estimated_hours).to_f
180 if average == 0
180 if average == 0
181 average = 1
181 average = 1
182 end
182 end
183 @estimated_average = average
183 @estimated_average = average
184 end
184 end
185 @estimated_average
185 @estimated_average
186 end
186 end
187
187
188 # Returns the total progress of open or closed issues. The returned percentage takes into account
188 # Returns the total progress of open or closed issues. The returned percentage takes into account
189 # the amount of estimated time set for this version.
189 # the amount of estimated time set for this version.
190 #
190 #
191 # Examples:
191 # Examples:
192 # issues_progress(true) => returns the progress percentage for open issues.
192 # issues_progress(true) => returns the progress percentage for open issues.
193 # issues_progress(false) => returns the progress percentage for closed issues.
193 # issues_progress(false) => returns the progress percentage for closed issues.
194 def issues_progress(open)
194 def issues_progress(open)
195 @issues_progress ||= {}
195 @issues_progress ||= {}
196 @issues_progress[open] ||= begin
196 @issues_progress[open] ||= begin
197 progress = 0
197 progress = 0
198 if issues_count > 0
198 if issues_count > 0
199 ratio = open ? 'done_ratio' : 100
199 ratio = open ? 'done_ratio' : 100
200
200
201 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
201 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
202 :include => :status,
202 :include => :status,
203 :conditions => ["is_closed = ?", !open]).to_f
203 :conditions => ["is_closed = ?", !open]).to_f
204 progress = done / (estimated_average * issues_count)
204 progress = done / (estimated_average * issues_count)
205 end
205 end
206 progress
206 progress
207 end
207 end
208 end
208 end
209 end
209 end
General Comments 0
You need to be logged in to leave comments. Login now