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