##// END OF EJS Templates
Adds a custom validator for dates (#12736)....
Jean-Philippe Lang -
r10894:3e14c3017c03
parent child
Show More
@@ -1,1401 +1,1395
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 include Redmine::Utils::DateCalculation
20 include Redmine::Utils::DateCalculation
21
21
22 belongs_to :project
22 belongs_to :project
23 belongs_to :tracker
23 belongs_to :tracker
24 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
25 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
26 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
26 belongs_to :assigned_to, :class_name => 'Principal', :foreign_key => 'assigned_to_id'
27 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
28 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
29 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
30
30
31 has_many :journals, :as => :journalized, :dependent => :destroy
31 has_many :journals, :as => :journalized, :dependent => :destroy
32 has_many :visible_journals,
32 has_many :visible_journals,
33 :class_name => 'Journal',
33 :class_name => 'Journal',
34 :as => :journalized,
34 :as => :journalized,
35 :conditions => Proc.new {
35 :conditions => Proc.new {
36 ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
36 ["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false]
37 },
37 },
38 :readonly => true
38 :readonly => true
39
39
40 has_many :time_entries, :dependent => :delete_all
40 has_many :time_entries, :dependent => :delete_all
41 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
41 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
42
42
43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45
45
46 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
46 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
47 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
47 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
48 acts_as_customizable
48 acts_as_customizable
49 acts_as_watchable
49 acts_as_watchable
50 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
50 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
51 :include => [:project, :visible_journals],
51 :include => [:project, :visible_journals],
52 # sort by id so that limited eager loading doesn't break with postgresql
52 # sort by id so that limited eager loading doesn't break with postgresql
53 :order_column => "#{table_name}.id"
53 :order_column => "#{table_name}.id"
54 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
54 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
55 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
55 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
56 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
56 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
57
57
58 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
58 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
59 :author_key => :author_id
59 :author_key => :author_id
60
60
61 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
61 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
62
62
63 attr_reader :current_journal
63 attr_reader :current_journal
64 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
64 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
65
65
66 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
66 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
67
67
68 validates_length_of :subject, :maximum => 255
68 validates_length_of :subject, :maximum => 255
69 validates_inclusion_of :done_ratio, :in => 0..100
69 validates_inclusion_of :done_ratio, :in => 0..100
70 validates_numericality_of :estimated_hours, :allow_nil => true
70 validates_numericality_of :estimated_hours, :allow_nil => true
71 validates :start_date, :date => true
72 validates :due_date, :date => true
71 validate :validate_issue, :validate_required_fields
73 validate :validate_issue, :validate_required_fields
72
74
73 scope :visible, lambda {|*args|
75 scope :visible, lambda {|*args|
74 includes(:project).where(Issue.visible_condition(args.shift || User.current, *args))
76 includes(:project).where(Issue.visible_condition(args.shift || User.current, *args))
75 }
77 }
76
78
77 scope :open, lambda {|*args|
79 scope :open, lambda {|*args|
78 is_closed = args.size > 0 ? !args.first : false
80 is_closed = args.size > 0 ? !args.first : false
79 includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
81 includes(:status).where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
80 }
82 }
81
83
82 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
84 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
83 scope :on_active_project, lambda {
85 scope :on_active_project, lambda {
84 includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
86 includes(:status, :project, :tracker).where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
85 }
87 }
86
88
87 before_create :default_assign
89 before_create :default_assign
88 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change
90 before_save :close_duplicates, :update_done_ratio_from_issue_status, :force_updated_on_change
89 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
91 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
90 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
92 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
91 # Should be after_create but would be called before previous after_save callbacks
93 # Should be after_create but would be called before previous after_save callbacks
92 after_save :after_create_from_copy
94 after_save :after_create_from_copy
93 after_destroy :update_parent_attributes
95 after_destroy :update_parent_attributes
94
96
95 # Returns a SQL conditions string used to find all issues visible by the specified user
97 # Returns a SQL conditions string used to find all issues visible by the specified user
96 def self.visible_condition(user, options={})
98 def self.visible_condition(user, options={})
97 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
99 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
98 if user.logged?
100 if user.logged?
99 case role.issues_visibility
101 case role.issues_visibility
100 when 'all'
102 when 'all'
101 nil
103 nil
102 when 'default'
104 when 'default'
103 user_ids = [user.id] + user.groups.map(&:id)
105 user_ids = [user.id] + user.groups.map(&:id)
104 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
106 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
105 when 'own'
107 when 'own'
106 user_ids = [user.id] + user.groups.map(&:id)
108 user_ids = [user.id] + user.groups.map(&:id)
107 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
109 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
108 else
110 else
109 '1=0'
111 '1=0'
110 end
112 end
111 else
113 else
112 "(#{table_name}.is_private = #{connection.quoted_false})"
114 "(#{table_name}.is_private = #{connection.quoted_false})"
113 end
115 end
114 end
116 end
115 end
117 end
116
118
117 # Returns true if usr or current user is allowed to view the issue
119 # Returns true if usr or current user is allowed to view the issue
118 def visible?(usr=nil)
120 def visible?(usr=nil)
119 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
121 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
120 if user.logged?
122 if user.logged?
121 case role.issues_visibility
123 case role.issues_visibility
122 when 'all'
124 when 'all'
123 true
125 true
124 when 'default'
126 when 'default'
125 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
127 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
126 when 'own'
128 when 'own'
127 self.author == user || user.is_or_belongs_to?(assigned_to)
129 self.author == user || user.is_or_belongs_to?(assigned_to)
128 else
130 else
129 false
131 false
130 end
132 end
131 else
133 else
132 !self.is_private?
134 !self.is_private?
133 end
135 end
134 end
136 end
135 end
137 end
136
138
137 # Returns true if user or current user is allowed to edit or add a note to the issue
139 # Returns true if user or current user is allowed to edit or add a note to the issue
138 def editable?(user=User.current)
140 def editable?(user=User.current)
139 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
141 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
140 end
142 end
141
143
142 def initialize(attributes=nil, *args)
144 def initialize(attributes=nil, *args)
143 super
145 super
144 if new_record?
146 if new_record?
145 # set default values for new records only
147 # set default values for new records only
146 self.status ||= IssueStatus.default
148 self.status ||= IssueStatus.default
147 self.priority ||= IssuePriority.default
149 self.priority ||= IssuePriority.default
148 self.watcher_user_ids = []
150 self.watcher_user_ids = []
149 end
151 end
150 end
152 end
151
153
152 # AR#Persistence#destroy would raise and RecordNotFound exception
154 # AR#Persistence#destroy would raise and RecordNotFound exception
153 # if the issue was already deleted or updated (non matching lock_version).
155 # if the issue was already deleted or updated (non matching lock_version).
154 # This is a problem when bulk deleting issues or deleting a project
156 # This is a problem when bulk deleting issues or deleting a project
155 # (because an issue may already be deleted if its parent was deleted
157 # (because an issue may already be deleted if its parent was deleted
156 # first).
158 # first).
157 # The issue is reloaded by the nested_set before being deleted so
159 # The issue is reloaded by the nested_set before being deleted so
158 # the lock_version condition should not be an issue but we handle it.
160 # the lock_version condition should not be an issue but we handle it.
159 def destroy
161 def destroy
160 super
162 super
161 rescue ActiveRecord::RecordNotFound
163 rescue ActiveRecord::RecordNotFound
162 # Stale or already deleted
164 # Stale or already deleted
163 begin
165 begin
164 reload
166 reload
165 rescue ActiveRecord::RecordNotFound
167 rescue ActiveRecord::RecordNotFound
166 # The issue was actually already deleted
168 # The issue was actually already deleted
167 @destroyed = true
169 @destroyed = true
168 return freeze
170 return freeze
169 end
171 end
170 # The issue was stale, retry to destroy
172 # The issue was stale, retry to destroy
171 super
173 super
172 end
174 end
173
175
174 def reload(*args)
176 def reload(*args)
175 @workflow_rule_by_attribute = nil
177 @workflow_rule_by_attribute = nil
176 @assignable_versions = nil
178 @assignable_versions = nil
177 super
179 super
178 end
180 end
179
181
180 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
182 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
181 def available_custom_fields
183 def available_custom_fields
182 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
184 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
183 end
185 end
184
186
185 # Copies attributes from another issue, arg can be an id or an Issue
187 # Copies attributes from another issue, arg can be an id or an Issue
186 def copy_from(arg, options={})
188 def copy_from(arg, options={})
187 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
189 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
188 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
190 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
189 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
191 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
190 self.status = issue.status
192 self.status = issue.status
191 self.author = User.current
193 self.author = User.current
192 unless options[:attachments] == false
194 unless options[:attachments] == false
193 self.attachments = issue.attachments.map do |attachement|
195 self.attachments = issue.attachments.map do |attachement|
194 attachement.copy(:container => self)
196 attachement.copy(:container => self)
195 end
197 end
196 end
198 end
197 @copied_from = issue
199 @copied_from = issue
198 @copy_options = options
200 @copy_options = options
199 self
201 self
200 end
202 end
201
203
202 # Returns an unsaved copy of the issue
204 # Returns an unsaved copy of the issue
203 def copy(attributes=nil, copy_options={})
205 def copy(attributes=nil, copy_options={})
204 copy = self.class.new.copy_from(self, copy_options)
206 copy = self.class.new.copy_from(self, copy_options)
205 copy.attributes = attributes if attributes
207 copy.attributes = attributes if attributes
206 copy
208 copy
207 end
209 end
208
210
209 # Returns true if the issue is a copy
211 # Returns true if the issue is a copy
210 def copy?
212 def copy?
211 @copied_from.present?
213 @copied_from.present?
212 end
214 end
213
215
214 # Moves/copies an issue to a new project and tracker
216 # Moves/copies an issue to a new project and tracker
215 # Returns the moved/copied issue on success, false on failure
217 # Returns the moved/copied issue on success, false on failure
216 def move_to_project(new_project, new_tracker=nil, options={})
218 def move_to_project(new_project, new_tracker=nil, options={})
217 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
219 ActiveSupport::Deprecation.warn "Issue#move_to_project is deprecated, use #project= instead."
218
220
219 if options[:copy]
221 if options[:copy]
220 issue = self.copy
222 issue = self.copy
221 else
223 else
222 issue = self
224 issue = self
223 end
225 end
224
226
225 issue.init_journal(User.current, options[:notes])
227 issue.init_journal(User.current, options[:notes])
226
228
227 # Preserve previous behaviour
229 # Preserve previous behaviour
228 # #move_to_project doesn't change tracker automatically
230 # #move_to_project doesn't change tracker automatically
229 issue.send :project=, new_project, true
231 issue.send :project=, new_project, true
230 if new_tracker
232 if new_tracker
231 issue.tracker = new_tracker
233 issue.tracker = new_tracker
232 end
234 end
233 # Allow bulk setting of attributes on the issue
235 # Allow bulk setting of attributes on the issue
234 if options[:attributes]
236 if options[:attributes]
235 issue.attributes = options[:attributes]
237 issue.attributes = options[:attributes]
236 end
238 end
237
239
238 issue.save ? issue : false
240 issue.save ? issue : false
239 end
241 end
240
242
241 def status_id=(sid)
243 def status_id=(sid)
242 self.status = nil
244 self.status = nil
243 result = write_attribute(:status_id, sid)
245 result = write_attribute(:status_id, sid)
244 @workflow_rule_by_attribute = nil
246 @workflow_rule_by_attribute = nil
245 result
247 result
246 end
248 end
247
249
248 def priority_id=(pid)
250 def priority_id=(pid)
249 self.priority = nil
251 self.priority = nil
250 write_attribute(:priority_id, pid)
252 write_attribute(:priority_id, pid)
251 end
253 end
252
254
253 def category_id=(cid)
255 def category_id=(cid)
254 self.category = nil
256 self.category = nil
255 write_attribute(:category_id, cid)
257 write_attribute(:category_id, cid)
256 end
258 end
257
259
258 def fixed_version_id=(vid)
260 def fixed_version_id=(vid)
259 self.fixed_version = nil
261 self.fixed_version = nil
260 write_attribute(:fixed_version_id, vid)
262 write_attribute(:fixed_version_id, vid)
261 end
263 end
262
264
263 def tracker_id=(tid)
265 def tracker_id=(tid)
264 self.tracker = nil
266 self.tracker = nil
265 result = write_attribute(:tracker_id, tid)
267 result = write_attribute(:tracker_id, tid)
266 @custom_field_values = nil
268 @custom_field_values = nil
267 @workflow_rule_by_attribute = nil
269 @workflow_rule_by_attribute = nil
268 result
270 result
269 end
271 end
270
272
271 def project_id=(project_id)
273 def project_id=(project_id)
272 if project_id.to_s != self.project_id.to_s
274 if project_id.to_s != self.project_id.to_s
273 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
275 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
274 end
276 end
275 end
277 end
276
278
277 def project=(project, keep_tracker=false)
279 def project=(project, keep_tracker=false)
278 project_was = self.project
280 project_was = self.project
279 write_attribute(:project_id, project ? project.id : nil)
281 write_attribute(:project_id, project ? project.id : nil)
280 association_instance_set('project', project)
282 association_instance_set('project', project)
281 if project_was && project && project_was != project
283 if project_was && project && project_was != project
282 @assignable_versions = nil
284 @assignable_versions = nil
283
285
284 unless keep_tracker || project.trackers.include?(tracker)
286 unless keep_tracker || project.trackers.include?(tracker)
285 self.tracker = project.trackers.first
287 self.tracker = project.trackers.first
286 end
288 end
287 # Reassign to the category with same name if any
289 # Reassign to the category with same name if any
288 if category
290 if category
289 self.category = project.issue_categories.find_by_name(category.name)
291 self.category = project.issue_categories.find_by_name(category.name)
290 end
292 end
291 # Keep the fixed_version if it's still valid in the new_project
293 # Keep the fixed_version if it's still valid in the new_project
292 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
294 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
293 self.fixed_version = nil
295 self.fixed_version = nil
294 end
296 end
295 # Clear the parent task if it's no longer valid
297 # Clear the parent task if it's no longer valid
296 unless valid_parent_project?
298 unless valid_parent_project?
297 self.parent_issue_id = nil
299 self.parent_issue_id = nil
298 end
300 end
299 @custom_field_values = nil
301 @custom_field_values = nil
300 end
302 end
301 end
303 end
302
304
303 def description=(arg)
305 def description=(arg)
304 if arg.is_a?(String)
306 if arg.is_a?(String)
305 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
307 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
306 end
308 end
307 write_attribute(:description, arg)
309 write_attribute(:description, arg)
308 end
310 end
309
311
310 # Overrides assign_attributes so that project and tracker get assigned first
312 # Overrides assign_attributes so that project and tracker get assigned first
311 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
313 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
312 return if new_attributes.nil?
314 return if new_attributes.nil?
313 attrs = new_attributes.dup
315 attrs = new_attributes.dup
314 attrs.stringify_keys!
316 attrs.stringify_keys!
315
317
316 %w(project project_id tracker tracker_id).each do |attr|
318 %w(project project_id tracker tracker_id).each do |attr|
317 if attrs.has_key?(attr)
319 if attrs.has_key?(attr)
318 send "#{attr}=", attrs.delete(attr)
320 send "#{attr}=", attrs.delete(attr)
319 end
321 end
320 end
322 end
321 send :assign_attributes_without_project_and_tracker_first, attrs, *args
323 send :assign_attributes_without_project_and_tracker_first, attrs, *args
322 end
324 end
323 # Do not redefine alias chain on reload (see #4838)
325 # Do not redefine alias chain on reload (see #4838)
324 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
326 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
325
327
326 def estimated_hours=(h)
328 def estimated_hours=(h)
327 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
329 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
328 end
330 end
329
331
330 safe_attributes 'project_id',
332 safe_attributes 'project_id',
331 :if => lambda {|issue, user|
333 :if => lambda {|issue, user|
332 if issue.new_record?
334 if issue.new_record?
333 issue.copy?
335 issue.copy?
334 elsif user.allowed_to?(:move_issues, issue.project)
336 elsif user.allowed_to?(:move_issues, issue.project)
335 projects = Issue.allowed_target_projects_on_move(user)
337 projects = Issue.allowed_target_projects_on_move(user)
336 projects.include?(issue.project) && projects.size > 1
338 projects.include?(issue.project) && projects.size > 1
337 end
339 end
338 }
340 }
339
341
340 safe_attributes 'tracker_id',
342 safe_attributes 'tracker_id',
341 'status_id',
343 'status_id',
342 'category_id',
344 'category_id',
343 'assigned_to_id',
345 'assigned_to_id',
344 'priority_id',
346 'priority_id',
345 'fixed_version_id',
347 'fixed_version_id',
346 'subject',
348 'subject',
347 'description',
349 'description',
348 'start_date',
350 'start_date',
349 'due_date',
351 'due_date',
350 'done_ratio',
352 'done_ratio',
351 'estimated_hours',
353 'estimated_hours',
352 'custom_field_values',
354 'custom_field_values',
353 'custom_fields',
355 'custom_fields',
354 'lock_version',
356 'lock_version',
355 'notes',
357 'notes',
356 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
358 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
357
359
358 safe_attributes 'status_id',
360 safe_attributes 'status_id',
359 'assigned_to_id',
361 'assigned_to_id',
360 'fixed_version_id',
362 'fixed_version_id',
361 'done_ratio',
363 'done_ratio',
362 'lock_version',
364 'lock_version',
363 'notes',
365 'notes',
364 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
366 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
365
367
366 safe_attributes 'notes',
368 safe_attributes 'notes',
367 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
369 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
368
370
369 safe_attributes 'private_notes',
371 safe_attributes 'private_notes',
370 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
372 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
371
373
372 safe_attributes 'watcher_user_ids',
374 safe_attributes 'watcher_user_ids',
373 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
375 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
374
376
375 safe_attributes 'is_private',
377 safe_attributes 'is_private',
376 :if => lambda {|issue, user|
378 :if => lambda {|issue, user|
377 user.allowed_to?(:set_issues_private, issue.project) ||
379 user.allowed_to?(:set_issues_private, issue.project) ||
378 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
380 (issue.author == user && user.allowed_to?(:set_own_issues_private, issue.project))
379 }
381 }
380
382
381 safe_attributes 'parent_issue_id',
383 safe_attributes 'parent_issue_id',
382 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
384 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
383 user.allowed_to?(:manage_subtasks, issue.project)}
385 user.allowed_to?(:manage_subtasks, issue.project)}
384
386
385 def safe_attribute_names(user=nil)
387 def safe_attribute_names(user=nil)
386 names = super
388 names = super
387 names -= disabled_core_fields
389 names -= disabled_core_fields
388 names -= read_only_attribute_names(user)
390 names -= read_only_attribute_names(user)
389 names
391 names
390 end
392 end
391
393
392 # Safely sets attributes
394 # Safely sets attributes
393 # Should be called from controllers instead of #attributes=
395 # Should be called from controllers instead of #attributes=
394 # attr_accessible is too rough because we still want things like
396 # attr_accessible is too rough because we still want things like
395 # Issue.new(:project => foo) to work
397 # Issue.new(:project => foo) to work
396 def safe_attributes=(attrs, user=User.current)
398 def safe_attributes=(attrs, user=User.current)
397 return unless attrs.is_a?(Hash)
399 return unless attrs.is_a?(Hash)
398
400
399 attrs = attrs.dup
401 attrs = attrs.dup
400
402
401 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
403 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
402 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
404 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
403 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
405 if allowed_target_projects(user).collect(&:id).include?(p.to_i)
404 self.project_id = p
406 self.project_id = p
405 end
407 end
406 end
408 end
407
409
408 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
410 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
409 self.tracker_id = t
411 self.tracker_id = t
410 end
412 end
411
413
412 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
414 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
413 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
415 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
414 self.status_id = s
416 self.status_id = s
415 end
417 end
416 end
418 end
417
419
418 attrs = delete_unsafe_attributes(attrs, user)
420 attrs = delete_unsafe_attributes(attrs, user)
419 return if attrs.empty?
421 return if attrs.empty?
420
422
421 unless leaf?
423 unless leaf?
422 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
424 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
423 end
425 end
424
426
425 if attrs['parent_issue_id'].present?
427 if attrs['parent_issue_id'].present?
426 s = attrs['parent_issue_id'].to_s
428 s = attrs['parent_issue_id'].to_s
427 unless (m = s.match(%r{\A#?(\d+)\z})) && Issue.visible(user).exists?(m[1])
429 unless (m = s.match(%r{\A#?(\d+)\z})) && Issue.visible(user).exists?(m[1])
428 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
430 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
429 end
431 end
430 end
432 end
431
433
432 if attrs['custom_field_values'].present?
434 if attrs['custom_field_values'].present?
433 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| read_only_attribute_names(user).include? k.to_s}
435 attrs['custom_field_values'] = attrs['custom_field_values'].reject {|k, v| read_only_attribute_names(user).include? k.to_s}
434 end
436 end
435
437
436 if attrs['custom_fields'].present?
438 if attrs['custom_fields'].present?
437 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| read_only_attribute_names(user).include? c['id'].to_s}
439 attrs['custom_fields'] = attrs['custom_fields'].reject {|c| read_only_attribute_names(user).include? c['id'].to_s}
438 end
440 end
439
441
440 # mass-assignment security bypass
442 # mass-assignment security bypass
441 assign_attributes attrs, :without_protection => true
443 assign_attributes attrs, :without_protection => true
442 end
444 end
443
445
444 def disabled_core_fields
446 def disabled_core_fields
445 tracker ? tracker.disabled_core_fields : []
447 tracker ? tracker.disabled_core_fields : []
446 end
448 end
447
449
448 # Returns the custom_field_values that can be edited by the given user
450 # Returns the custom_field_values that can be edited by the given user
449 def editable_custom_field_values(user=nil)
451 def editable_custom_field_values(user=nil)
450 custom_field_values.reject do |value|
452 custom_field_values.reject do |value|
451 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
453 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
452 end
454 end
453 end
455 end
454
456
455 # Returns the names of attributes that are read-only for user or the current user
457 # Returns the names of attributes that are read-only for user or the current user
456 # For users with multiple roles, the read-only fields are the intersection of
458 # For users with multiple roles, the read-only fields are the intersection of
457 # read-only fields of each role
459 # read-only fields of each role
458 # The result is an array of strings where sustom fields are represented with their ids
460 # The result is an array of strings where sustom fields are represented with their ids
459 #
461 #
460 # Examples:
462 # Examples:
461 # issue.read_only_attribute_names # => ['due_date', '2']
463 # issue.read_only_attribute_names # => ['due_date', '2']
462 # issue.read_only_attribute_names(user) # => []
464 # issue.read_only_attribute_names(user) # => []
463 def read_only_attribute_names(user=nil)
465 def read_only_attribute_names(user=nil)
464 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
466 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
465 end
467 end
466
468
467 # Returns the names of required attributes for user or the current user
469 # Returns the names of required attributes for user or the current user
468 # For users with multiple roles, the required fields are the intersection of
470 # For users with multiple roles, the required fields are the intersection of
469 # required fields of each role
471 # required fields of each role
470 # The result is an array of strings where sustom fields are represented with their ids
472 # The result is an array of strings where sustom fields are represented with their ids
471 #
473 #
472 # Examples:
474 # Examples:
473 # issue.required_attribute_names # => ['due_date', '2']
475 # issue.required_attribute_names # => ['due_date', '2']
474 # issue.required_attribute_names(user) # => []
476 # issue.required_attribute_names(user) # => []
475 def required_attribute_names(user=nil)
477 def required_attribute_names(user=nil)
476 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
478 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
477 end
479 end
478
480
479 # Returns true if the attribute is required for user
481 # Returns true if the attribute is required for user
480 def required_attribute?(name, user=nil)
482 def required_attribute?(name, user=nil)
481 required_attribute_names(user).include?(name.to_s)
483 required_attribute_names(user).include?(name.to_s)
482 end
484 end
483
485
484 # Returns a hash of the workflow rule by attribute for the given user
486 # Returns a hash of the workflow rule by attribute for the given user
485 #
487 #
486 # Examples:
488 # Examples:
487 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
489 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
488 def workflow_rule_by_attribute(user=nil)
490 def workflow_rule_by_attribute(user=nil)
489 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
491 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
490
492
491 user_real = user || User.current
493 user_real = user || User.current
492 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
494 roles = user_real.admin ? Role.all : user_real.roles_for_project(project)
493 return {} if roles.empty?
495 return {} if roles.empty?
494
496
495 result = {}
497 result = {}
496 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
498 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).all
497 if workflow_permissions.any?
499 if workflow_permissions.any?
498 workflow_rules = workflow_permissions.inject({}) do |h, wp|
500 workflow_rules = workflow_permissions.inject({}) do |h, wp|
499 h[wp.field_name] ||= []
501 h[wp.field_name] ||= []
500 h[wp.field_name] << wp.rule
502 h[wp.field_name] << wp.rule
501 h
503 h
502 end
504 end
503 workflow_rules.each do |attr, rules|
505 workflow_rules.each do |attr, rules|
504 next if rules.size < roles.size
506 next if rules.size < roles.size
505 uniq_rules = rules.uniq
507 uniq_rules = rules.uniq
506 if uniq_rules.size == 1
508 if uniq_rules.size == 1
507 result[attr] = uniq_rules.first
509 result[attr] = uniq_rules.first
508 else
510 else
509 result[attr] = 'required'
511 result[attr] = 'required'
510 end
512 end
511 end
513 end
512 end
514 end
513 @workflow_rule_by_attribute = result if user.nil?
515 @workflow_rule_by_attribute = result if user.nil?
514 result
516 result
515 end
517 end
516 private :workflow_rule_by_attribute
518 private :workflow_rule_by_attribute
517
519
518 def done_ratio
520 def done_ratio
519 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
521 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
520 status.default_done_ratio
522 status.default_done_ratio
521 else
523 else
522 read_attribute(:done_ratio)
524 read_attribute(:done_ratio)
523 end
525 end
524 end
526 end
525
527
526 def self.use_status_for_done_ratio?
528 def self.use_status_for_done_ratio?
527 Setting.issue_done_ratio == 'issue_status'
529 Setting.issue_done_ratio == 'issue_status'
528 end
530 end
529
531
530 def self.use_field_for_done_ratio?
532 def self.use_field_for_done_ratio?
531 Setting.issue_done_ratio == 'issue_field'
533 Setting.issue_done_ratio == 'issue_field'
532 end
534 end
533
535
534 def validate_issue
536 def validate_issue
535 if due_date.nil? && @attributes['due_date'].present?
536 errors.add :due_date, :not_a_date
537 end
538
539 if start_date.nil? && @attributes['start_date'].present?
540 errors.add :start_date, :not_a_date
541 end
542
543 if due_date && start_date && due_date < start_date
537 if due_date && start_date && due_date < start_date
544 errors.add :due_date, :greater_than_start_date
538 errors.add :due_date, :greater_than_start_date
545 end
539 end
546
540
547 if start_date && soonest_start && start_date < soonest_start
541 if start_date && soonest_start && start_date < soonest_start
548 errors.add :start_date, :invalid
542 errors.add :start_date, :invalid
549 end
543 end
550
544
551 if fixed_version
545 if fixed_version
552 if !assignable_versions.include?(fixed_version)
546 if !assignable_versions.include?(fixed_version)
553 errors.add :fixed_version_id, :inclusion
547 errors.add :fixed_version_id, :inclusion
554 elsif reopened? && fixed_version.closed?
548 elsif reopened? && fixed_version.closed?
555 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
549 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
556 end
550 end
557 end
551 end
558
552
559 # Checks that the issue can not be added/moved to a disabled tracker
553 # Checks that the issue can not be added/moved to a disabled tracker
560 if project && (tracker_id_changed? || project_id_changed?)
554 if project && (tracker_id_changed? || project_id_changed?)
561 unless project.trackers.include?(tracker)
555 unless project.trackers.include?(tracker)
562 errors.add :tracker_id, :inclusion
556 errors.add :tracker_id, :inclusion
563 end
557 end
564 end
558 end
565
559
566 # Checks parent issue assignment
560 # Checks parent issue assignment
567 if @invalid_parent_issue_id.present?
561 if @invalid_parent_issue_id.present?
568 errors.add :parent_issue_id, :invalid
562 errors.add :parent_issue_id, :invalid
569 elsif @parent_issue
563 elsif @parent_issue
570 if !valid_parent_project?(@parent_issue)
564 if !valid_parent_project?(@parent_issue)
571 errors.add :parent_issue_id, :invalid
565 errors.add :parent_issue_id, :invalid
572 elsif !new_record?
566 elsif !new_record?
573 # moving an existing issue
567 # moving an existing issue
574 if @parent_issue.root_id != root_id
568 if @parent_issue.root_id != root_id
575 # we can always move to another tree
569 # we can always move to another tree
576 elsif move_possible?(@parent_issue)
570 elsif move_possible?(@parent_issue)
577 # move accepted inside tree
571 # move accepted inside tree
578 else
572 else
579 errors.add :parent_issue_id, :invalid
573 errors.add :parent_issue_id, :invalid
580 end
574 end
581 end
575 end
582 end
576 end
583 end
577 end
584
578
585 # Validates the issue against additional workflow requirements
579 # Validates the issue against additional workflow requirements
586 def validate_required_fields
580 def validate_required_fields
587 user = new_record? ? author : current_journal.try(:user)
581 user = new_record? ? author : current_journal.try(:user)
588
582
589 required_attribute_names(user).each do |attribute|
583 required_attribute_names(user).each do |attribute|
590 if attribute =~ /^\d+$/
584 if attribute =~ /^\d+$/
591 attribute = attribute.to_i
585 attribute = attribute.to_i
592 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
586 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
593 if v && v.value.blank?
587 if v && v.value.blank?
594 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
588 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
595 end
589 end
596 else
590 else
597 if respond_to?(attribute) && send(attribute).blank?
591 if respond_to?(attribute) && send(attribute).blank?
598 errors.add attribute, :blank
592 errors.add attribute, :blank
599 end
593 end
600 end
594 end
601 end
595 end
602 end
596 end
603
597
604 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
598 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
605 # even if the user turns off the setting later
599 # even if the user turns off the setting later
606 def update_done_ratio_from_issue_status
600 def update_done_ratio_from_issue_status
607 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
601 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
608 self.done_ratio = status.default_done_ratio
602 self.done_ratio = status.default_done_ratio
609 end
603 end
610 end
604 end
611
605
612 def init_journal(user, notes = "")
606 def init_journal(user, notes = "")
613 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
607 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
614 if new_record?
608 if new_record?
615 @current_journal.notify = false
609 @current_journal.notify = false
616 else
610 else
617 @attributes_before_change = attributes.dup
611 @attributes_before_change = attributes.dup
618 @custom_values_before_change = {}
612 @custom_values_before_change = {}
619 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
613 self.custom_field_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
620 end
614 end
621 @current_journal
615 @current_journal
622 end
616 end
623
617
624 # Returns the id of the last journal or nil
618 # Returns the id of the last journal or nil
625 def last_journal_id
619 def last_journal_id
626 if new_record?
620 if new_record?
627 nil
621 nil
628 else
622 else
629 journals.maximum(:id)
623 journals.maximum(:id)
630 end
624 end
631 end
625 end
632
626
633 # Returns a scope for journals that have an id greater than journal_id
627 # Returns a scope for journals that have an id greater than journal_id
634 def journals_after(journal_id)
628 def journals_after(journal_id)
635 scope = journals.reorder("#{Journal.table_name}.id ASC")
629 scope = journals.reorder("#{Journal.table_name}.id ASC")
636 if journal_id.present?
630 if journal_id.present?
637 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
631 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
638 end
632 end
639 scope
633 scope
640 end
634 end
641
635
642 # Return true if the issue is closed, otherwise false
636 # Return true if the issue is closed, otherwise false
643 def closed?
637 def closed?
644 self.status.is_closed?
638 self.status.is_closed?
645 end
639 end
646
640
647 # Return true if the issue is being reopened
641 # Return true if the issue is being reopened
648 def reopened?
642 def reopened?
649 if !new_record? && status_id_changed?
643 if !new_record? && status_id_changed?
650 status_was = IssueStatus.find_by_id(status_id_was)
644 status_was = IssueStatus.find_by_id(status_id_was)
651 status_new = IssueStatus.find_by_id(status_id)
645 status_new = IssueStatus.find_by_id(status_id)
652 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
646 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
653 return true
647 return true
654 end
648 end
655 end
649 end
656 false
650 false
657 end
651 end
658
652
659 # Return true if the issue is being closed
653 # Return true if the issue is being closed
660 def closing?
654 def closing?
661 if !new_record? && status_id_changed?
655 if !new_record? && status_id_changed?
662 status_was = IssueStatus.find_by_id(status_id_was)
656 status_was = IssueStatus.find_by_id(status_id_was)
663 status_new = IssueStatus.find_by_id(status_id)
657 status_new = IssueStatus.find_by_id(status_id)
664 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
658 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
665 return true
659 return true
666 end
660 end
667 end
661 end
668 false
662 false
669 end
663 end
670
664
671 # Returns true if the issue is overdue
665 # Returns true if the issue is overdue
672 def overdue?
666 def overdue?
673 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
667 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
674 end
668 end
675
669
676 # Is the amount of work done less than it should for the due date
670 # Is the amount of work done less than it should for the due date
677 def behind_schedule?
671 def behind_schedule?
678 return false if start_date.nil? || due_date.nil?
672 return false if start_date.nil? || due_date.nil?
679 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
673 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
680 return done_date <= Date.today
674 return done_date <= Date.today
681 end
675 end
682
676
683 # Does this issue have children?
677 # Does this issue have children?
684 def children?
678 def children?
685 !leaf?
679 !leaf?
686 end
680 end
687
681
688 # Users the issue can be assigned to
682 # Users the issue can be assigned to
689 def assignable_users
683 def assignable_users
690 users = project.assignable_users
684 users = project.assignable_users
691 users << author if author
685 users << author if author
692 users << assigned_to if assigned_to
686 users << assigned_to if assigned_to
693 users.uniq.sort
687 users.uniq.sort
694 end
688 end
695
689
696 # Versions that the issue can be assigned to
690 # Versions that the issue can be assigned to
697 def assignable_versions
691 def assignable_versions
698 return @assignable_versions if @assignable_versions
692 return @assignable_versions if @assignable_versions
699
693
700 versions = project.shared_versions.open.all
694 versions = project.shared_versions.open.all
701 if fixed_version
695 if fixed_version
702 if fixed_version_id_changed?
696 if fixed_version_id_changed?
703 # nothing to do
697 # nothing to do
704 elsif project_id_changed?
698 elsif project_id_changed?
705 if project.shared_versions.include?(fixed_version)
699 if project.shared_versions.include?(fixed_version)
706 versions << fixed_version
700 versions << fixed_version
707 end
701 end
708 else
702 else
709 versions << fixed_version
703 versions << fixed_version
710 end
704 end
711 end
705 end
712 @assignable_versions = versions.uniq.sort
706 @assignable_versions = versions.uniq.sort
713 end
707 end
714
708
715 # Returns true if this issue is blocked by another issue that is still open
709 # Returns true if this issue is blocked by another issue that is still open
716 def blocked?
710 def blocked?
717 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
711 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
718 end
712 end
719
713
720 # Returns an array of statuses that user is able to apply
714 # Returns an array of statuses that user is able to apply
721 def new_statuses_allowed_to(user=User.current, include_default=false)
715 def new_statuses_allowed_to(user=User.current, include_default=false)
722 if new_record? && @copied_from
716 if new_record? && @copied_from
723 [IssueStatus.default, @copied_from.status].compact.uniq.sort
717 [IssueStatus.default, @copied_from.status].compact.uniq.sort
724 else
718 else
725 initial_status = nil
719 initial_status = nil
726 if new_record?
720 if new_record?
727 initial_status = IssueStatus.default
721 initial_status = IssueStatus.default
728 elsif status_id_was
722 elsif status_id_was
729 initial_status = IssueStatus.find_by_id(status_id_was)
723 initial_status = IssueStatus.find_by_id(status_id_was)
730 end
724 end
731 initial_status ||= status
725 initial_status ||= status
732
726
733 statuses = initial_status.find_new_statuses_allowed_to(
727 statuses = initial_status.find_new_statuses_allowed_to(
734 user.admin ? Role.all : user.roles_for_project(project),
728 user.admin ? Role.all : user.roles_for_project(project),
735 tracker,
729 tracker,
736 author == user,
730 author == user,
737 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
731 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
738 )
732 )
739 statuses << initial_status unless statuses.empty?
733 statuses << initial_status unless statuses.empty?
740 statuses << IssueStatus.default if include_default
734 statuses << IssueStatus.default if include_default
741 statuses = statuses.compact.uniq.sort
735 statuses = statuses.compact.uniq.sort
742 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
736 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
743 end
737 end
744 end
738 end
745
739
746 def assigned_to_was
740 def assigned_to_was
747 if assigned_to_id_changed? && assigned_to_id_was.present?
741 if assigned_to_id_changed? && assigned_to_id_was.present?
748 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
742 @assigned_to_was ||= User.find_by_id(assigned_to_id_was)
749 end
743 end
750 end
744 end
751
745
752 # Returns the users that should be notified
746 # Returns the users that should be notified
753 def notified_users
747 def notified_users
754 notified = []
748 notified = []
755 # Author and assignee are always notified unless they have been
749 # Author and assignee are always notified unless they have been
756 # locked or don't want to be notified
750 # locked or don't want to be notified
757 notified << author if author
751 notified << author if author
758 if assigned_to
752 if assigned_to
759 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
753 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
760 end
754 end
761 if assigned_to_was
755 if assigned_to_was
762 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
756 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
763 end
757 end
764 notified = notified.select {|u| u.active? && u.notify_about?(self)}
758 notified = notified.select {|u| u.active? && u.notify_about?(self)}
765
759
766 notified += project.notified_users
760 notified += project.notified_users
767 notified.uniq!
761 notified.uniq!
768 # Remove users that can not view the issue
762 # Remove users that can not view the issue
769 notified.reject! {|user| !visible?(user)}
763 notified.reject! {|user| !visible?(user)}
770 notified
764 notified
771 end
765 end
772
766
773 # Returns the email addresses that should be notified
767 # Returns the email addresses that should be notified
774 def recipients
768 def recipients
775 notified_users.collect(&:mail)
769 notified_users.collect(&:mail)
776 end
770 end
777
771
778 # Returns the number of hours spent on this issue
772 # Returns the number of hours spent on this issue
779 def spent_hours
773 def spent_hours
780 @spent_hours ||= time_entries.sum(:hours) || 0
774 @spent_hours ||= time_entries.sum(:hours) || 0
781 end
775 end
782
776
783 # Returns the total number of hours spent on this issue and its descendants
777 # Returns the total number of hours spent on this issue and its descendants
784 #
778 #
785 # Example:
779 # Example:
786 # spent_hours => 0.0
780 # spent_hours => 0.0
787 # spent_hours => 50.2
781 # spent_hours => 50.2
788 def total_spent_hours
782 def total_spent_hours
789 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
783 @total_spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours",
790 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
784 :joins => "LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").to_f || 0.0
791 end
785 end
792
786
793 def relations
787 def relations
794 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
788 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
795 end
789 end
796
790
797 # Preloads relations for a collection of issues
791 # Preloads relations for a collection of issues
798 def self.load_relations(issues)
792 def self.load_relations(issues)
799 if issues.any?
793 if issues.any?
800 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
794 relations = IssueRelation.all(:conditions => ["issue_from_id IN (:ids) OR issue_to_id IN (:ids)", {:ids => issues.map(&:id)}])
801 issues.each do |issue|
795 issues.each do |issue|
802 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
796 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
803 end
797 end
804 end
798 end
805 end
799 end
806
800
807 # Preloads visible spent time for a collection of issues
801 # Preloads visible spent time for a collection of issues
808 def self.load_visible_spent_hours(issues, user=User.current)
802 def self.load_visible_spent_hours(issues, user=User.current)
809 if issues.any?
803 if issues.any?
810 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
804 hours_by_issue_id = TimeEntry.visible(user).sum(:hours, :group => :issue_id)
811 issues.each do |issue|
805 issues.each do |issue|
812 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
806 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
813 end
807 end
814 end
808 end
815 end
809 end
816
810
817 # Preloads visible relations for a collection of issues
811 # Preloads visible relations for a collection of issues
818 def self.load_visible_relations(issues, user=User.current)
812 def self.load_visible_relations(issues, user=User.current)
819 if issues.any?
813 if issues.any?
820 issue_ids = issues.map(&:id)
814 issue_ids = issues.map(&:id)
821 # Relations with issue_from in given issues and visible issue_to
815 # Relations with issue_from in given issues and visible issue_to
822 relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
816 relations_from = IssueRelation.includes(:issue_to => [:status, :project]).where(visible_condition(user)).where(:issue_from_id => issue_ids).all
823 # Relations with issue_to in given issues and visible issue_from
817 # Relations with issue_to in given issues and visible issue_from
824 relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
818 relations_to = IssueRelation.includes(:issue_from => [:status, :project]).where(visible_condition(user)).where(:issue_to_id => issue_ids).all
825
819
826 issues.each do |issue|
820 issues.each do |issue|
827 relations =
821 relations =
828 relations_from.select {|relation| relation.issue_from_id == issue.id} +
822 relations_from.select {|relation| relation.issue_from_id == issue.id} +
829 relations_to.select {|relation| relation.issue_to_id == issue.id}
823 relations_to.select {|relation| relation.issue_to_id == issue.id}
830
824
831 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
825 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
832 end
826 end
833 end
827 end
834 end
828 end
835
829
836 # Finds an issue relation given its id.
830 # Finds an issue relation given its id.
837 def find_relation(relation_id)
831 def find_relation(relation_id)
838 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
832 IssueRelation.find(relation_id, :conditions => ["issue_to_id = ? OR issue_from_id = ?", id, id])
839 end
833 end
840
834
841 def all_dependent_issues(except=[])
835 def all_dependent_issues(except=[])
842 except << self
836 except << self
843 dependencies = []
837 dependencies = []
844 relations_from.each do |relation|
838 relations_from.each do |relation|
845 if relation.issue_to && !except.include?(relation.issue_to)
839 if relation.issue_to && !except.include?(relation.issue_to)
846 dependencies << relation.issue_to
840 dependencies << relation.issue_to
847 dependencies += relation.issue_to.all_dependent_issues(except)
841 dependencies += relation.issue_to.all_dependent_issues(except)
848 end
842 end
849 end
843 end
850 dependencies
844 dependencies
851 end
845 end
852
846
853 # Returns an array of issues that duplicate this one
847 # Returns an array of issues that duplicate this one
854 def duplicates
848 def duplicates
855 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
849 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
856 end
850 end
857
851
858 # Returns the due date or the target due date if any
852 # Returns the due date or the target due date if any
859 # Used on gantt chart
853 # Used on gantt chart
860 def due_before
854 def due_before
861 due_date || (fixed_version ? fixed_version.effective_date : nil)
855 due_date || (fixed_version ? fixed_version.effective_date : nil)
862 end
856 end
863
857
864 # Returns the time scheduled for this issue.
858 # Returns the time scheduled for this issue.
865 #
859 #
866 # Example:
860 # Example:
867 # Start Date: 2/26/09, End Date: 3/04/09
861 # Start Date: 2/26/09, End Date: 3/04/09
868 # duration => 6
862 # duration => 6
869 def duration
863 def duration
870 (start_date && due_date) ? due_date - start_date : 0
864 (start_date && due_date) ? due_date - start_date : 0
871 end
865 end
872
866
873 # Returns the duration in working days
867 # Returns the duration in working days
874 def working_duration
868 def working_duration
875 (start_date && due_date) ? working_days(start_date, due_date) : 0
869 (start_date && due_date) ? working_days(start_date, due_date) : 0
876 end
870 end
877
871
878 def soonest_start(reload=false)
872 def soonest_start(reload=false)
879 @soonest_start = nil if reload
873 @soonest_start = nil if reload
880 @soonest_start ||= (
874 @soonest_start ||= (
881 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
875 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
882 ancestors.collect(&:soonest_start)
876 ancestors.collect(&:soonest_start)
883 ).compact.max
877 ).compact.max
884 end
878 end
885
879
886 # Sets start_date on the given date or the next working day
880 # Sets start_date on the given date or the next working day
887 # and changes due_date to keep the same working duration.
881 # and changes due_date to keep the same working duration.
888 def reschedule_on(date)
882 def reschedule_on(date)
889 wd = working_duration
883 wd = working_duration
890 date = next_working_date(date)
884 date = next_working_date(date)
891 self.start_date = date
885 self.start_date = date
892 self.due_date = add_working_days(date, wd)
886 self.due_date = add_working_days(date, wd)
893 end
887 end
894
888
895 # Reschedules the issue on the given date or the next working day and saves the record.
889 # Reschedules the issue on the given date or the next working day and saves the record.
896 # If the issue is a parent task, this is done by rescheduling its subtasks.
890 # If the issue is a parent task, this is done by rescheduling its subtasks.
897 def reschedule_on!(date)
891 def reschedule_on!(date)
898 return if date.nil?
892 return if date.nil?
899 if leaf?
893 if leaf?
900 if start_date.nil? || start_date != date
894 if start_date.nil? || start_date != date
901 if start_date && start_date > date
895 if start_date && start_date > date
902 # Issue can not be moved earlier than its soonest start date
896 # Issue can not be moved earlier than its soonest start date
903 date = [soonest_start(true), date].compact.max
897 date = [soonest_start(true), date].compact.max
904 end
898 end
905 reschedule_on(date)
899 reschedule_on(date)
906 begin
900 begin
907 save
901 save
908 rescue ActiveRecord::StaleObjectError
902 rescue ActiveRecord::StaleObjectError
909 reload
903 reload
910 reschedule_on(date)
904 reschedule_on(date)
911 save
905 save
912 end
906 end
913 end
907 end
914 else
908 else
915 leaves.each do |leaf|
909 leaves.each do |leaf|
916 if leaf.start_date
910 if leaf.start_date
917 # Only move subtask if it starts at the same date as the parent
911 # Only move subtask if it starts at the same date as the parent
918 # or if it starts before the given date
912 # or if it starts before the given date
919 if start_date == leaf.start_date || date > leaf.start_date
913 if start_date == leaf.start_date || date > leaf.start_date
920 leaf.reschedule_on!(date)
914 leaf.reschedule_on!(date)
921 end
915 end
922 else
916 else
923 leaf.reschedule_on!(date)
917 leaf.reschedule_on!(date)
924 end
918 end
925 end
919 end
926 end
920 end
927 end
921 end
928
922
929 def <=>(issue)
923 def <=>(issue)
930 if issue.nil?
924 if issue.nil?
931 -1
925 -1
932 elsif root_id != issue.root_id
926 elsif root_id != issue.root_id
933 (root_id || 0) <=> (issue.root_id || 0)
927 (root_id || 0) <=> (issue.root_id || 0)
934 else
928 else
935 (lft || 0) <=> (issue.lft || 0)
929 (lft || 0) <=> (issue.lft || 0)
936 end
930 end
937 end
931 end
938
932
939 def to_s
933 def to_s
940 "#{tracker} ##{id}: #{subject}"
934 "#{tracker} ##{id}: #{subject}"
941 end
935 end
942
936
943 # Returns a string of css classes that apply to the issue
937 # Returns a string of css classes that apply to the issue
944 def css_classes
938 def css_classes
945 s = "issue status-#{status_id} #{priority.try(:css_classes)}"
939 s = "issue status-#{status_id} #{priority.try(:css_classes)}"
946 s << ' closed' if closed?
940 s << ' closed' if closed?
947 s << ' overdue' if overdue?
941 s << ' overdue' if overdue?
948 s << ' child' if child?
942 s << ' child' if child?
949 s << ' parent' unless leaf?
943 s << ' parent' unless leaf?
950 s << ' private' if is_private?
944 s << ' private' if is_private?
951 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
945 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
952 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
946 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
953 s
947 s
954 end
948 end
955
949
956 # Saves an issue and a time_entry from the parameters
950 # Saves an issue and a time_entry from the parameters
957 def save_issue_with_child_records(params, existing_time_entry=nil)
951 def save_issue_with_child_records(params, existing_time_entry=nil)
958 Issue.transaction do
952 Issue.transaction do
959 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
953 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
960 @time_entry = existing_time_entry || TimeEntry.new
954 @time_entry = existing_time_entry || TimeEntry.new
961 @time_entry.project = project
955 @time_entry.project = project
962 @time_entry.issue = self
956 @time_entry.issue = self
963 @time_entry.user = User.current
957 @time_entry.user = User.current
964 @time_entry.spent_on = User.current.today
958 @time_entry.spent_on = User.current.today
965 @time_entry.attributes = params[:time_entry]
959 @time_entry.attributes = params[:time_entry]
966 self.time_entries << @time_entry
960 self.time_entries << @time_entry
967 end
961 end
968
962
969 # TODO: Rename hook
963 # TODO: Rename hook
970 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
964 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
971 if save
965 if save
972 # TODO: Rename hook
966 # TODO: Rename hook
973 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
967 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
974 else
968 else
975 raise ActiveRecord::Rollback
969 raise ActiveRecord::Rollback
976 end
970 end
977 end
971 end
978 end
972 end
979
973
980 # Unassigns issues from +version+ if it's no longer shared with issue's project
974 # Unassigns issues from +version+ if it's no longer shared with issue's project
981 def self.update_versions_from_sharing_change(version)
975 def self.update_versions_from_sharing_change(version)
982 # Update issues assigned to the version
976 # Update issues assigned to the version
983 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
977 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
984 end
978 end
985
979
986 # Unassigns issues from versions that are no longer shared
980 # Unassigns issues from versions that are no longer shared
987 # after +project+ was moved
981 # after +project+ was moved
988 def self.update_versions_from_hierarchy_change(project)
982 def self.update_versions_from_hierarchy_change(project)
989 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
983 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
990 # Update issues of the moved projects and issues assigned to a version of a moved project
984 # Update issues of the moved projects and issues assigned to a version of a moved project
991 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
985 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
992 end
986 end
993
987
994 def parent_issue_id=(arg)
988 def parent_issue_id=(arg)
995 s = arg.to_s.strip.presence
989 s = arg.to_s.strip.presence
996 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
990 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
997 @parent_issue.id
991 @parent_issue.id
998 else
992 else
999 @parent_issue = nil
993 @parent_issue = nil
1000 @invalid_parent_issue_id = arg
994 @invalid_parent_issue_id = arg
1001 end
995 end
1002 end
996 end
1003
997
1004 def parent_issue_id
998 def parent_issue_id
1005 if @invalid_parent_issue_id
999 if @invalid_parent_issue_id
1006 @invalid_parent_issue_id
1000 @invalid_parent_issue_id
1007 elsif instance_variable_defined? :@parent_issue
1001 elsif instance_variable_defined? :@parent_issue
1008 @parent_issue.nil? ? nil : @parent_issue.id
1002 @parent_issue.nil? ? nil : @parent_issue.id
1009 else
1003 else
1010 parent_id
1004 parent_id
1011 end
1005 end
1012 end
1006 end
1013
1007
1014 # Returns true if issue's project is a valid
1008 # Returns true if issue's project is a valid
1015 # parent issue project
1009 # parent issue project
1016 def valid_parent_project?(issue=parent)
1010 def valid_parent_project?(issue=parent)
1017 return true if issue.nil? || issue.project_id == project_id
1011 return true if issue.nil? || issue.project_id == project_id
1018
1012
1019 case Setting.cross_project_subtasks
1013 case Setting.cross_project_subtasks
1020 when 'system'
1014 when 'system'
1021 true
1015 true
1022 when 'tree'
1016 when 'tree'
1023 issue.project.root == project.root
1017 issue.project.root == project.root
1024 when 'hierarchy'
1018 when 'hierarchy'
1025 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1019 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1026 when 'descendants'
1020 when 'descendants'
1027 issue.project.is_or_is_ancestor_of?(project)
1021 issue.project.is_or_is_ancestor_of?(project)
1028 else
1022 else
1029 false
1023 false
1030 end
1024 end
1031 end
1025 end
1032
1026
1033 # Extracted from the ReportsController.
1027 # Extracted from the ReportsController.
1034 def self.by_tracker(project)
1028 def self.by_tracker(project)
1035 count_and_group_by(:project => project,
1029 count_and_group_by(:project => project,
1036 :field => 'tracker_id',
1030 :field => 'tracker_id',
1037 :joins => Tracker.table_name)
1031 :joins => Tracker.table_name)
1038 end
1032 end
1039
1033
1040 def self.by_version(project)
1034 def self.by_version(project)
1041 count_and_group_by(:project => project,
1035 count_and_group_by(:project => project,
1042 :field => 'fixed_version_id',
1036 :field => 'fixed_version_id',
1043 :joins => Version.table_name)
1037 :joins => Version.table_name)
1044 end
1038 end
1045
1039
1046 def self.by_priority(project)
1040 def self.by_priority(project)
1047 count_and_group_by(:project => project,
1041 count_and_group_by(:project => project,
1048 :field => 'priority_id',
1042 :field => 'priority_id',
1049 :joins => IssuePriority.table_name)
1043 :joins => IssuePriority.table_name)
1050 end
1044 end
1051
1045
1052 def self.by_category(project)
1046 def self.by_category(project)
1053 count_and_group_by(:project => project,
1047 count_and_group_by(:project => project,
1054 :field => 'category_id',
1048 :field => 'category_id',
1055 :joins => IssueCategory.table_name)
1049 :joins => IssueCategory.table_name)
1056 end
1050 end
1057
1051
1058 def self.by_assigned_to(project)
1052 def self.by_assigned_to(project)
1059 count_and_group_by(:project => project,
1053 count_and_group_by(:project => project,
1060 :field => 'assigned_to_id',
1054 :field => 'assigned_to_id',
1061 :joins => User.table_name)
1055 :joins => User.table_name)
1062 end
1056 end
1063
1057
1064 def self.by_author(project)
1058 def self.by_author(project)
1065 count_and_group_by(:project => project,
1059 count_and_group_by(:project => project,
1066 :field => 'author_id',
1060 :field => 'author_id',
1067 :joins => User.table_name)
1061 :joins => User.table_name)
1068 end
1062 end
1069
1063
1070 def self.by_subproject(project)
1064 def self.by_subproject(project)
1071 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1065 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1072 s.is_closed as closed,
1066 s.is_closed as closed,
1073 #{Issue.table_name}.project_id as project_id,
1067 #{Issue.table_name}.project_id as project_id,
1074 count(#{Issue.table_name}.id) as total
1068 count(#{Issue.table_name}.id) as total
1075 from
1069 from
1076 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
1070 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
1077 where
1071 where
1078 #{Issue.table_name}.status_id=s.id
1072 #{Issue.table_name}.status_id=s.id
1079 and #{Issue.table_name}.project_id = #{Project.table_name}.id
1073 and #{Issue.table_name}.project_id = #{Project.table_name}.id
1080 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
1074 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
1081 and #{Issue.table_name}.project_id <> #{project.id}
1075 and #{Issue.table_name}.project_id <> #{project.id}
1082 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
1076 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
1083 end
1077 end
1084 # End ReportsController extraction
1078 # End ReportsController extraction
1085
1079
1086 # Returns an array of projects that user can assign the issue to
1080 # Returns an array of projects that user can assign the issue to
1087 def allowed_target_projects(user=User.current)
1081 def allowed_target_projects(user=User.current)
1088 if new_record?
1082 if new_record?
1089 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
1083 Project.all(:conditions => Project.allowed_to_condition(user, :add_issues))
1090 else
1084 else
1091 self.class.allowed_target_projects_on_move(user)
1085 self.class.allowed_target_projects_on_move(user)
1092 end
1086 end
1093 end
1087 end
1094
1088
1095 # Returns an array of projects that user can move issues to
1089 # Returns an array of projects that user can move issues to
1096 def self.allowed_target_projects_on_move(user=User.current)
1090 def self.allowed_target_projects_on_move(user=User.current)
1097 Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
1091 Project.all(:conditions => Project.allowed_to_condition(user, :move_issues))
1098 end
1092 end
1099
1093
1100 private
1094 private
1101
1095
1102 def after_project_change
1096 def after_project_change
1103 # Update project_id on related time entries
1097 # Update project_id on related time entries
1104 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
1098 TimeEntry.update_all(["project_id = ?", project_id], {:issue_id => id})
1105
1099
1106 # Delete issue relations
1100 # Delete issue relations
1107 unless Setting.cross_project_issue_relations?
1101 unless Setting.cross_project_issue_relations?
1108 relations_from.clear
1102 relations_from.clear
1109 relations_to.clear
1103 relations_to.clear
1110 end
1104 end
1111
1105
1112 # Move subtasks that were in the same project
1106 # Move subtasks that were in the same project
1113 children.each do |child|
1107 children.each do |child|
1114 next unless child.project_id == project_id_was
1108 next unless child.project_id == project_id_was
1115 # Change project and keep project
1109 # Change project and keep project
1116 child.send :project=, project, true
1110 child.send :project=, project, true
1117 unless child.save
1111 unless child.save
1118 raise ActiveRecord::Rollback
1112 raise ActiveRecord::Rollback
1119 end
1113 end
1120 end
1114 end
1121 end
1115 end
1122
1116
1123 # Callback for after the creation of an issue by copy
1117 # Callback for after the creation of an issue by copy
1124 # * adds a "copied to" relation with the copied issue
1118 # * adds a "copied to" relation with the copied issue
1125 # * copies subtasks from the copied issue
1119 # * copies subtasks from the copied issue
1126 def after_create_from_copy
1120 def after_create_from_copy
1127 return unless copy? && !@after_create_from_copy_handled
1121 return unless copy? && !@after_create_from_copy_handled
1128
1122
1129 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1123 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1130 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1124 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1131 unless relation.save
1125 unless relation.save
1132 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1126 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1133 end
1127 end
1134 end
1128 end
1135
1129
1136 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1130 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1137 @copied_from.children.each do |child|
1131 @copied_from.children.each do |child|
1138 unless child.visible?
1132 unless child.visible?
1139 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1133 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1140 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1134 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1141 next
1135 next
1142 end
1136 end
1143 copy = Issue.new.copy_from(child, @copy_options)
1137 copy = Issue.new.copy_from(child, @copy_options)
1144 copy.author = author
1138 copy.author = author
1145 copy.project = project
1139 copy.project = project
1146 copy.parent_issue_id = id
1140 copy.parent_issue_id = id
1147 # Children subtasks are copied recursively
1141 # Children subtasks are copied recursively
1148 unless copy.save
1142 unless copy.save
1149 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1143 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1150 end
1144 end
1151 end
1145 end
1152 end
1146 end
1153 @after_create_from_copy_handled = true
1147 @after_create_from_copy_handled = true
1154 end
1148 end
1155
1149
1156 def update_nested_set_attributes
1150 def update_nested_set_attributes
1157 if root_id.nil?
1151 if root_id.nil?
1158 # issue was just created
1152 # issue was just created
1159 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1153 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
1160 set_default_left_and_right
1154 set_default_left_and_right
1161 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
1155 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
1162 if @parent_issue
1156 if @parent_issue
1163 move_to_child_of(@parent_issue)
1157 move_to_child_of(@parent_issue)
1164 end
1158 end
1165 reload
1159 reload
1166 elsif parent_issue_id != parent_id
1160 elsif parent_issue_id != parent_id
1167 former_parent_id = parent_id
1161 former_parent_id = parent_id
1168 # moving an existing issue
1162 # moving an existing issue
1169 if @parent_issue && @parent_issue.root_id == root_id
1163 if @parent_issue && @parent_issue.root_id == root_id
1170 # inside the same tree
1164 # inside the same tree
1171 move_to_child_of(@parent_issue)
1165 move_to_child_of(@parent_issue)
1172 else
1166 else
1173 # to another tree
1167 # to another tree
1174 unless root?
1168 unless root?
1175 move_to_right_of(root)
1169 move_to_right_of(root)
1176 reload
1170 reload
1177 end
1171 end
1178 old_root_id = root_id
1172 old_root_id = root_id
1179 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
1173 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
1180 target_maxright = nested_set_scope.maximum(right_column_name) || 0
1174 target_maxright = nested_set_scope.maximum(right_column_name) || 0
1181 offset = target_maxright + 1 - lft
1175 offset = target_maxright + 1 - lft
1182 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
1176 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
1183 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
1177 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
1184 self[left_column_name] = lft + offset
1178 self[left_column_name] = lft + offset
1185 self[right_column_name] = rgt + offset
1179 self[right_column_name] = rgt + offset
1186 if @parent_issue
1180 if @parent_issue
1187 move_to_child_of(@parent_issue)
1181 move_to_child_of(@parent_issue)
1188 end
1182 end
1189 end
1183 end
1190 reload
1184 reload
1191 # delete invalid relations of all descendants
1185 # delete invalid relations of all descendants
1192 self_and_descendants.each do |issue|
1186 self_and_descendants.each do |issue|
1193 issue.relations.each do |relation|
1187 issue.relations.each do |relation|
1194 relation.destroy unless relation.valid?
1188 relation.destroy unless relation.valid?
1195 end
1189 end
1196 end
1190 end
1197 # update former parent
1191 # update former parent
1198 recalculate_attributes_for(former_parent_id) if former_parent_id
1192 recalculate_attributes_for(former_parent_id) if former_parent_id
1199 end
1193 end
1200 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1194 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1201 end
1195 end
1202
1196
1203 def update_parent_attributes
1197 def update_parent_attributes
1204 recalculate_attributes_for(parent_id) if parent_id
1198 recalculate_attributes_for(parent_id) if parent_id
1205 end
1199 end
1206
1200
1207 def recalculate_attributes_for(issue_id)
1201 def recalculate_attributes_for(issue_id)
1208 if issue_id && p = Issue.find_by_id(issue_id)
1202 if issue_id && p = Issue.find_by_id(issue_id)
1209 # priority = highest priority of children
1203 # priority = highest priority of children
1210 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
1204 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :joins => :priority)
1211 p.priority = IssuePriority.find_by_position(priority_position)
1205 p.priority = IssuePriority.find_by_position(priority_position)
1212 end
1206 end
1213
1207
1214 # start/due dates = lowest/highest dates of children
1208 # start/due dates = lowest/highest dates of children
1215 p.start_date = p.children.minimum(:start_date)
1209 p.start_date = p.children.minimum(:start_date)
1216 p.due_date = p.children.maximum(:due_date)
1210 p.due_date = p.children.maximum(:due_date)
1217 if p.start_date && p.due_date && p.due_date < p.start_date
1211 if p.start_date && p.due_date && p.due_date < p.start_date
1218 p.start_date, p.due_date = p.due_date, p.start_date
1212 p.start_date, p.due_date = p.due_date, p.start_date
1219 end
1213 end
1220
1214
1221 # done ratio = weighted average ratio of leaves
1215 # done ratio = weighted average ratio of leaves
1222 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1216 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1223 leaves_count = p.leaves.count
1217 leaves_count = p.leaves.count
1224 if leaves_count > 0
1218 if leaves_count > 0
1225 average = p.leaves.average(:estimated_hours).to_f
1219 average = p.leaves.average(:estimated_hours).to_f
1226 if average == 0
1220 if average == 0
1227 average = 1
1221 average = 1
1228 end
1222 end
1229 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :joins => :status).to_f
1223 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :joins => :status).to_f
1230 progress = done / (average * leaves_count)
1224 progress = done / (average * leaves_count)
1231 p.done_ratio = progress.round
1225 p.done_ratio = progress.round
1232 end
1226 end
1233 end
1227 end
1234
1228
1235 # estimate = sum of leaves estimates
1229 # estimate = sum of leaves estimates
1236 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1230 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1237 p.estimated_hours = nil if p.estimated_hours == 0.0
1231 p.estimated_hours = nil if p.estimated_hours == 0.0
1238
1232
1239 # ancestors will be recursively updated
1233 # ancestors will be recursively updated
1240 p.save(:validate => false)
1234 p.save(:validate => false)
1241 end
1235 end
1242 end
1236 end
1243
1237
1244 # Update issues so their versions are not pointing to a
1238 # Update issues so their versions are not pointing to a
1245 # fixed_version that is not shared with the issue's project
1239 # fixed_version that is not shared with the issue's project
1246 def self.update_versions(conditions=nil)
1240 def self.update_versions(conditions=nil)
1247 # Only need to update issues with a fixed_version from
1241 # Only need to update issues with a fixed_version from
1248 # a different project and that is not systemwide shared
1242 # a different project and that is not systemwide shared
1249 Issue.scoped(:conditions => conditions).all(
1243 Issue.scoped(:conditions => conditions).all(
1250 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1244 :conditions => "#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1251 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1245 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1252 " AND #{Version.table_name}.sharing <> 'system'",
1246 " AND #{Version.table_name}.sharing <> 'system'",
1253 :include => [:project, :fixed_version]
1247 :include => [:project, :fixed_version]
1254 ).each do |issue|
1248 ).each do |issue|
1255 next if issue.project.nil? || issue.fixed_version.nil?
1249 next if issue.project.nil? || issue.fixed_version.nil?
1256 unless issue.project.shared_versions.include?(issue.fixed_version)
1250 unless issue.project.shared_versions.include?(issue.fixed_version)
1257 issue.init_journal(User.current)
1251 issue.init_journal(User.current)
1258 issue.fixed_version = nil
1252 issue.fixed_version = nil
1259 issue.save
1253 issue.save
1260 end
1254 end
1261 end
1255 end
1262 end
1256 end
1263
1257
1264 # Callback on file attachment
1258 # Callback on file attachment
1265 def attachment_added(obj)
1259 def attachment_added(obj)
1266 if @current_journal && !obj.new_record?
1260 if @current_journal && !obj.new_record?
1267 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1261 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :value => obj.filename)
1268 end
1262 end
1269 end
1263 end
1270
1264
1271 # Callback on attachment deletion
1265 # Callback on attachment deletion
1272 def attachment_removed(obj)
1266 def attachment_removed(obj)
1273 if @current_journal && !obj.new_record?
1267 if @current_journal && !obj.new_record?
1274 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1268 @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => obj.id, :old_value => obj.filename)
1275 @current_journal.save
1269 @current_journal.save
1276 end
1270 end
1277 end
1271 end
1278
1272
1279 # Default assignment based on category
1273 # Default assignment based on category
1280 def default_assign
1274 def default_assign
1281 if assigned_to.nil? && category && category.assigned_to
1275 if assigned_to.nil? && category && category.assigned_to
1282 self.assigned_to = category.assigned_to
1276 self.assigned_to = category.assigned_to
1283 end
1277 end
1284 end
1278 end
1285
1279
1286 # Updates start/due dates of following issues
1280 # Updates start/due dates of following issues
1287 def reschedule_following_issues
1281 def reschedule_following_issues
1288 if start_date_changed? || due_date_changed?
1282 if start_date_changed? || due_date_changed?
1289 relations_from.each do |relation|
1283 relations_from.each do |relation|
1290 relation.set_issue_to_dates
1284 relation.set_issue_to_dates
1291 end
1285 end
1292 end
1286 end
1293 end
1287 end
1294
1288
1295 # Closes duplicates if the issue is being closed
1289 # Closes duplicates if the issue is being closed
1296 def close_duplicates
1290 def close_duplicates
1297 if closing?
1291 if closing?
1298 duplicates.each do |duplicate|
1292 duplicates.each do |duplicate|
1299 # Reload is need in case the duplicate was updated by a previous duplicate
1293 # Reload is need in case the duplicate was updated by a previous duplicate
1300 duplicate.reload
1294 duplicate.reload
1301 # Don't re-close it if it's already closed
1295 # Don't re-close it if it's already closed
1302 next if duplicate.closed?
1296 next if duplicate.closed?
1303 # Same user and notes
1297 # Same user and notes
1304 if @current_journal
1298 if @current_journal
1305 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1299 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1306 end
1300 end
1307 duplicate.update_attribute :status, self.status
1301 duplicate.update_attribute :status, self.status
1308 end
1302 end
1309 end
1303 end
1310 end
1304 end
1311
1305
1312 # Make sure updated_on is updated when adding a note
1306 # Make sure updated_on is updated when adding a note
1313 def force_updated_on_change
1307 def force_updated_on_change
1314 if @current_journal
1308 if @current_journal
1315 self.updated_on = current_time_from_proper_timezone
1309 self.updated_on = current_time_from_proper_timezone
1316 end
1310 end
1317 end
1311 end
1318
1312
1319 # Saves the changes in a Journal
1313 # Saves the changes in a Journal
1320 # Called after_save
1314 # Called after_save
1321 def create_journal
1315 def create_journal
1322 if @current_journal
1316 if @current_journal
1323 # attributes changes
1317 # attributes changes
1324 if @attributes_before_change
1318 if @attributes_before_change
1325 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
1319 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
1326 before = @attributes_before_change[c]
1320 before = @attributes_before_change[c]
1327 after = send(c)
1321 after = send(c)
1328 next if before == after || (before.blank? && after.blank?)
1322 next if before == after || (before.blank? && after.blank?)
1329 @current_journal.details << JournalDetail.new(:property => 'attr',
1323 @current_journal.details << JournalDetail.new(:property => 'attr',
1330 :prop_key => c,
1324 :prop_key => c,
1331 :old_value => before,
1325 :old_value => before,
1332 :value => after)
1326 :value => after)
1333 }
1327 }
1334 end
1328 end
1335 if @custom_values_before_change
1329 if @custom_values_before_change
1336 # custom fields changes
1330 # custom fields changes
1337 custom_field_values.each {|c|
1331 custom_field_values.each {|c|
1338 before = @custom_values_before_change[c.custom_field_id]
1332 before = @custom_values_before_change[c.custom_field_id]
1339 after = c.value
1333 after = c.value
1340 next if before == after || (before.blank? && after.blank?)
1334 next if before == after || (before.blank? && after.blank?)
1341
1335
1342 if before.is_a?(Array) || after.is_a?(Array)
1336 if before.is_a?(Array) || after.is_a?(Array)
1343 before = [before] unless before.is_a?(Array)
1337 before = [before] unless before.is_a?(Array)
1344 after = [after] unless after.is_a?(Array)
1338 after = [after] unless after.is_a?(Array)
1345
1339
1346 # values removed
1340 # values removed
1347 (before - after).reject(&:blank?).each do |value|
1341 (before - after).reject(&:blank?).each do |value|
1348 @current_journal.details << JournalDetail.new(:property => 'cf',
1342 @current_journal.details << JournalDetail.new(:property => 'cf',
1349 :prop_key => c.custom_field_id,
1343 :prop_key => c.custom_field_id,
1350 :old_value => value,
1344 :old_value => value,
1351 :value => nil)
1345 :value => nil)
1352 end
1346 end
1353 # values added
1347 # values added
1354 (after - before).reject(&:blank?).each do |value|
1348 (after - before).reject(&:blank?).each do |value|
1355 @current_journal.details << JournalDetail.new(:property => 'cf',
1349 @current_journal.details << JournalDetail.new(:property => 'cf',
1356 :prop_key => c.custom_field_id,
1350 :prop_key => c.custom_field_id,
1357 :old_value => nil,
1351 :old_value => nil,
1358 :value => value)
1352 :value => value)
1359 end
1353 end
1360 else
1354 else
1361 @current_journal.details << JournalDetail.new(:property => 'cf',
1355 @current_journal.details << JournalDetail.new(:property => 'cf',
1362 :prop_key => c.custom_field_id,
1356 :prop_key => c.custom_field_id,
1363 :old_value => before,
1357 :old_value => before,
1364 :value => after)
1358 :value => after)
1365 end
1359 end
1366 }
1360 }
1367 end
1361 end
1368 @current_journal.save
1362 @current_journal.save
1369 # reset current journal
1363 # reset current journal
1370 init_journal @current_journal.user, @current_journal.notes
1364 init_journal @current_journal.user, @current_journal.notes
1371 end
1365 end
1372 end
1366 end
1373
1367
1374 # Query generator for selecting groups of issue counts for a project
1368 # Query generator for selecting groups of issue counts for a project
1375 # based on specific criteria
1369 # based on specific criteria
1376 #
1370 #
1377 # Options
1371 # Options
1378 # * project - Project to search in.
1372 # * project - Project to search in.
1379 # * field - String. Issue field to key off of in the grouping.
1373 # * field - String. Issue field to key off of in the grouping.
1380 # * joins - String. The table name to join against.
1374 # * joins - String. The table name to join against.
1381 def self.count_and_group_by(options)
1375 def self.count_and_group_by(options)
1382 project = options.delete(:project)
1376 project = options.delete(:project)
1383 select_field = options.delete(:field)
1377 select_field = options.delete(:field)
1384 joins = options.delete(:joins)
1378 joins = options.delete(:joins)
1385
1379
1386 where = "#{Issue.table_name}.#{select_field}=j.id"
1380 where = "#{Issue.table_name}.#{select_field}=j.id"
1387
1381
1388 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1382 ActiveRecord::Base.connection.select_all("select s.id as status_id,
1389 s.is_closed as closed,
1383 s.is_closed as closed,
1390 j.id as #{select_field},
1384 j.id as #{select_field},
1391 count(#{Issue.table_name}.id) as total
1385 count(#{Issue.table_name}.id) as total
1392 from
1386 from
1393 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1387 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
1394 where
1388 where
1395 #{Issue.table_name}.status_id=s.id
1389 #{Issue.table_name}.status_id=s.id
1396 and #{where}
1390 and #{where}
1397 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1391 and #{Issue.table_name}.project_id=#{Project.table_name}.id
1398 and #{visible_condition(User.current, :project => project)}
1392 and #{visible_condition(User.current, :project => project)}
1399 group by s.id, s.is_closed, j.id")
1393 group by s.id, s.is_closed, j.id")
1400 end
1394 end
1401 end
1395 end
@@ -1,296 +1,289
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Version < ActiveRecord::Base
18 class Version < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
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', :dependent => :nullify
22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
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 => /\A\d{4}-\d{2}-\d{2}\z/, :message => :not_a_date, :allow_nil => true
33 validates :effective_date, :date => 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 validate :validate_version
37
36
38 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
37 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
39 scope :open, lambda { where(:status => 'open') }
38 scope :open, lambda { where(:status => 'open') }
40 scope :visible, lambda {|*args|
39 scope :visible, lambda {|*args|
41 includes(:project).where(Project.allowed_to_condition(args.first || User.current, :view_issues))
40 includes(:project).where(Project.allowed_to_condition(args.first || User.current, :view_issues))
42 }
41 }
43
42
44 safe_attributes 'name',
43 safe_attributes 'name',
45 'description',
44 'description',
46 'effective_date',
45 'effective_date',
47 'due_date',
46 'due_date',
48 'wiki_page_title',
47 'wiki_page_title',
49 'status',
48 'status',
50 'sharing',
49 'sharing',
51 'custom_field_values'
50 'custom_field_values'
52
51
53 # Returns true if +user+ or current user is allowed to view the version
52 # Returns true if +user+ or current user is allowed to view the version
54 def visible?(user=User.current)
53 def visible?(user=User.current)
55 user.allowed_to?(:view_issues, self.project)
54 user.allowed_to?(:view_issues, self.project)
56 end
55 end
57
56
58 # Version files have same visibility as project files
57 # Version files have same visibility as project files
59 def attachments_visible?(*args)
58 def attachments_visible?(*args)
60 project.present? && project.attachments_visible?(*args)
59 project.present? && project.attachments_visible?(*args)
61 end
60 end
62
61
63 def start_date
62 def start_date
64 @start_date ||= fixed_issues.minimum('start_date')
63 @start_date ||= fixed_issues.minimum('start_date')
65 end
64 end
66
65
67 def due_date
66 def due_date
68 effective_date
67 effective_date
69 end
68 end
70
69
71 def due_date=(arg)
70 def due_date=(arg)
72 self.effective_date=(arg)
71 self.effective_date=(arg)
73 end
72 end
74
73
75 # Returns the total estimated time for this version
74 # Returns the total estimated time for this version
76 # (sum of leaves estimated_hours)
75 # (sum of leaves estimated_hours)
77 def estimated_hours
76 def estimated_hours
78 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
77 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
79 end
78 end
80
79
81 # Returns the total reported time for this version
80 # Returns the total reported time for this version
82 def spent_hours
81 def spent_hours
83 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
82 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
84 end
83 end
85
84
86 def closed?
85 def closed?
87 status == 'closed'
86 status == 'closed'
88 end
87 end
89
88
90 def open?
89 def open?
91 status == 'open'
90 status == 'open'
92 end
91 end
93
92
94 # Returns true if the version is completed: due date reached and no open issues
93 # Returns true if the version is completed: due date reached and no open issues
95 def completed?
94 def completed?
96 effective_date && (effective_date < Date.today) && (open_issues_count == 0)
95 effective_date && (effective_date < Date.today) && (open_issues_count == 0)
97 end
96 end
98
97
99 def behind_schedule?
98 def behind_schedule?
100 if completed_percent == 100
99 if completed_percent == 100
101 return false
100 return false
102 elsif due_date && start_date
101 elsif due_date && start_date
103 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
102 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
104 return done_date <= Date.today
103 return done_date <= Date.today
105 else
104 else
106 false # No issues so it's not late
105 false # No issues so it's not late
107 end
106 end
108 end
107 end
109
108
110 # Returns the completion percentage of this version based on the amount of open/closed issues
109 # Returns the completion percentage of this version based on the amount of open/closed issues
111 # and the time spent on the open issues.
110 # and the time spent on the open issues.
112 def completed_percent
111 def completed_percent
113 if issues_count == 0
112 if issues_count == 0
114 0
113 0
115 elsif open_issues_count == 0
114 elsif open_issues_count == 0
116 100
115 100
117 else
116 else
118 issues_progress(false) + issues_progress(true)
117 issues_progress(false) + issues_progress(true)
119 end
118 end
120 end
119 end
121
120
122 # TODO: remove in Redmine 3.0
121 # TODO: remove in Redmine 3.0
123 def completed_pourcent
122 def completed_pourcent
124 ActiveSupport::Deprecation.warn "Version#completed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #completed_percent instead."
123 ActiveSupport::Deprecation.warn "Version#completed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #completed_percent instead."
125 completed_percent
124 completed_percent
126 end
125 end
127
126
128 # Returns the percentage of issues that have been marked as 'closed'.
127 # Returns the percentage of issues that have been marked as 'closed'.
129 def closed_percent
128 def closed_percent
130 if issues_count == 0
129 if issues_count == 0
131 0
130 0
132 else
131 else
133 issues_progress(false)
132 issues_progress(false)
134 end
133 end
135 end
134 end
136
135
137 # TODO: remove in Redmine 3.0
136 # TODO: remove in Redmine 3.0
138 def closed_pourcent
137 def closed_pourcent
139 ActiveSupport::Deprecation.warn "Version#closed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #closed_percent instead."
138 ActiveSupport::Deprecation.warn "Version#closed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #closed_percent instead."
140 closed_percent
139 closed_percent
141 end
140 end
142
141
143 # Returns true if the version is overdue: due date reached and some open issues
142 # Returns true if the version is overdue: due date reached and some open issues
144 def overdue?
143 def overdue?
145 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
144 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
146 end
145 end
147
146
148 # Returns assigned issues count
147 # Returns assigned issues count
149 def issues_count
148 def issues_count
150 load_issue_counts
149 load_issue_counts
151 @issue_count
150 @issue_count
152 end
151 end
153
152
154 # Returns the total amount of open issues for this version.
153 # Returns the total amount of open issues for this version.
155 def open_issues_count
154 def open_issues_count
156 load_issue_counts
155 load_issue_counts
157 @open_issues_count
156 @open_issues_count
158 end
157 end
159
158
160 # Returns the total amount of closed issues for this version.
159 # Returns the total amount of closed issues for this version.
161 def closed_issues_count
160 def closed_issues_count
162 load_issue_counts
161 load_issue_counts
163 @closed_issues_count
162 @closed_issues_count
164 end
163 end
165
164
166 def wiki_page
165 def wiki_page
167 if project.wiki && !wiki_page_title.blank?
166 if project.wiki && !wiki_page_title.blank?
168 @wiki_page ||= project.wiki.find_page(wiki_page_title)
167 @wiki_page ||= project.wiki.find_page(wiki_page_title)
169 end
168 end
170 @wiki_page
169 @wiki_page
171 end
170 end
172
171
173 def to_s; name end
172 def to_s; name end
174
173
175 def to_s_with_project
174 def to_s_with_project
176 "#{project} - #{name}"
175 "#{project} - #{name}"
177 end
176 end
178
177
179 # Versions are sorted by effective_date and name
178 # Versions are sorted by effective_date and name
180 # Those with no effective_date are at the end, sorted by name
179 # Those with no effective_date are at the end, sorted by name
181 def <=>(version)
180 def <=>(version)
182 if self.effective_date
181 if self.effective_date
183 if version.effective_date
182 if version.effective_date
184 if self.effective_date == version.effective_date
183 if self.effective_date == version.effective_date
185 name == version.name ? id <=> version.id : name <=> version.name
184 name == version.name ? id <=> version.id : name <=> version.name
186 else
185 else
187 self.effective_date <=> version.effective_date
186 self.effective_date <=> version.effective_date
188 end
187 end
189 else
188 else
190 -1
189 -1
191 end
190 end
192 else
191 else
193 if version.effective_date
192 if version.effective_date
194 1
193 1
195 else
194 else
196 name == version.name ? id <=> version.id : name <=> version.name
195 name == version.name ? id <=> version.id : name <=> version.name
197 end
196 end
198 end
197 end
199 end
198 end
200
199
201 def self.fields_for_order_statement(table=nil)
200 def self.fields_for_order_statement(table=nil)
202 table ||= table_name
201 table ||= table_name
203 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
202 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
204 end
203 end
205
204
206 scope :sorted, order(fields_for_order_statement)
205 scope :sorted, order(fields_for_order_statement)
207
206
208 # Returns the sharings that +user+ can set the version to
207 # Returns the sharings that +user+ can set the version to
209 def allowed_sharings(user = User.current)
208 def allowed_sharings(user = User.current)
210 VERSION_SHARINGS.select do |s|
209 VERSION_SHARINGS.select do |s|
211 if sharing == s
210 if sharing == s
212 true
211 true
213 else
212 else
214 case s
213 case s
215 when 'system'
214 when 'system'
216 # Only admin users can set a systemwide sharing
215 # Only admin users can set a systemwide sharing
217 user.admin?
216 user.admin?
218 when 'hierarchy', 'tree'
217 when 'hierarchy', 'tree'
219 # Only users allowed to manage versions of the root project can
218 # Only users allowed to manage versions of the root project can
220 # set sharing to hierarchy or tree
219 # set sharing to hierarchy or tree
221 project.nil? || user.allowed_to?(:manage_versions, project.root)
220 project.nil? || user.allowed_to?(:manage_versions, project.root)
222 else
221 else
223 true
222 true
224 end
223 end
225 end
224 end
226 end
225 end
227 end
226 end
228
227
229 private
228 private
230
229
231 def load_issue_counts
230 def load_issue_counts
232 unless @issue_count
231 unless @issue_count
233 @open_issues_count = 0
232 @open_issues_count = 0
234 @closed_issues_count = 0
233 @closed_issues_count = 0
235 fixed_issues.count(:all, :group => :status).each do |status, count|
234 fixed_issues.count(:all, :group => :status).each do |status, count|
236 if status.is_closed?
235 if status.is_closed?
237 @closed_issues_count += count
236 @closed_issues_count += count
238 else
237 else
239 @open_issues_count += count
238 @open_issues_count += count
240 end
239 end
241 end
240 end
242 @issue_count = @open_issues_count + @closed_issues_count
241 @issue_count = @open_issues_count + @closed_issues_count
243 end
242 end
244 end
243 end
245
244
246 # Update the issue's fixed versions. Used if a version's sharing changes.
245 # Update the issue's fixed versions. Used if a version's sharing changes.
247 def update_issues_from_sharing_change
246 def update_issues_from_sharing_change
248 if sharing_changed?
247 if sharing_changed?
249 if VERSION_SHARINGS.index(sharing_was).nil? ||
248 if VERSION_SHARINGS.index(sharing_was).nil? ||
250 VERSION_SHARINGS.index(sharing).nil? ||
249 VERSION_SHARINGS.index(sharing).nil? ||
251 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
250 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
252 Issue.update_versions_from_sharing_change self
251 Issue.update_versions_from_sharing_change self
253 end
252 end
254 end
253 end
255 end
254 end
256
255
257 # Returns the average estimated time of assigned issues
256 # Returns the average estimated time of assigned issues
258 # or 1 if no issue has an estimated time
257 # or 1 if no issue has an estimated time
259 # Used to weigth unestimated issues in progress calculation
258 # Used to weigth unestimated issues in progress calculation
260 def estimated_average
259 def estimated_average
261 if @estimated_average.nil?
260 if @estimated_average.nil?
262 average = fixed_issues.average(:estimated_hours).to_f
261 average = fixed_issues.average(:estimated_hours).to_f
263 if average == 0
262 if average == 0
264 average = 1
263 average = 1
265 end
264 end
266 @estimated_average = average
265 @estimated_average = average
267 end
266 end
268 @estimated_average
267 @estimated_average
269 end
268 end
270
269
271 # Returns the total progress of open or closed issues. The returned percentage takes into account
270 # Returns the total progress of open or closed issues. The returned percentage takes into account
272 # the amount of estimated time set for this version.
271 # the amount of estimated time set for this version.
273 #
272 #
274 # Examples:
273 # Examples:
275 # issues_progress(true) => returns the progress percentage for open issues.
274 # issues_progress(true) => returns the progress percentage for open issues.
276 # issues_progress(false) => returns the progress percentage for closed issues.
275 # issues_progress(false) => returns the progress percentage for closed issues.
277 def issues_progress(open)
276 def issues_progress(open)
278 @issues_progress ||= {}
277 @issues_progress ||= {}
279 @issues_progress[open] ||= begin
278 @issues_progress[open] ||= begin
280 progress = 0
279 progress = 0
281 if issues_count > 0
280 if issues_count > 0
282 ratio = open ? 'done_ratio' : 100
281 ratio = open ? 'done_ratio' : 100
283
282
284 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
283 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
285 progress = done / (estimated_average * issues_count)
284 progress = done / (estimated_average * issues_count)
286 end
285 end
287 progress
286 progress
288 end
287 end
289 end
288 end
290
291 def validate_version
292 if effective_date.nil? && @attributes['effective_date'].present?
293 errors.add :effective_date, :not_a_date
294 end
295 end
296 end
289 end
@@ -1,40 +1,51
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 module ActiveRecord
18 module ActiveRecord
19 module FinderMethods
19 module FinderMethods
20 def find_ids(*args)
20 def find_ids(*args)
21 find_ids_with_associations
21 find_ids_with_associations
22 end
22 end
23
23
24 private
24 private
25
25
26 def find_ids_with_associations
26 def find_ids_with_associations
27 join_dependency = construct_join_dependency_for_association_find
27 join_dependency = construct_join_dependency_for_association_find
28 relation = construct_relation_for_association_find_ids(join_dependency)
28 relation = construct_relation_for_association_find_ids(join_dependency)
29 rows = connection.select_all(relation, 'SQL', relation.bind_values)
29 rows = connection.select_all(relation, 'SQL', relation.bind_values)
30 rows.map {|row| row["id"].to_i}
30 rows.map {|row| row["id"].to_i}
31 rescue ThrowResult
31 rescue ThrowResult
32 []
32 []
33 end
33 end
34
34
35 def construct_relation_for_association_find_ids(join_dependency)
35 def construct_relation_for_association_find_ids(join_dependency)
36 relation = except(:includes, :eager_load, :preload, :select).select("#{table_name}.id")
36 relation = except(:includes, :eager_load, :preload, :select).select("#{table_name}.id")
37 apply_join_dependency(relation, join_dependency)
37 apply_join_dependency(relation, join_dependency)
38 end
38 end
39 end
39 end
40 end
40 end
41
42 class DateValidator < ActiveModel::EachValidator
43 def validate_each(record, attribute, value)
44 before_type_cast = record.attributes_before_type_cast[attribute.to_s]
45 if before_type_cast.is_a?(String) && before_type_cast.present?
46 unless before_type_cast =~ /\A\d{4}-\d{2}-\d{2}\z/ && value
47 record.errors.add attribute, :not_a_date
48 end
49 end
50 end
51 end
@@ -1,252 +1,254
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19
19
20 class VersionTest < ActiveSupport::TestCase
20 class VersionTest < ActiveSupport::TestCase
21 fixtures :projects, :users, :issues, :issue_statuses, :trackers, :enumerations, :versions, :projects_trackers
21 fixtures :projects, :users, :issues, :issue_statuses, :trackers, :enumerations, :versions, :projects_trackers
22
22
23 def setup
23 def setup
24 end
24 end
25
25
26 def test_create
26 def test_create
27 v = Version.new(:project => Project.find(1), :name => '1.1', :effective_date => '2011-03-25')
27 v = Version.new(:project => Project.find(1), :name => '1.1', :effective_date => '2011-03-25')
28 assert v.save
28 assert v.save
29 assert_equal 'open', v.status
29 assert_equal 'open', v.status
30 assert_equal 'none', v.sharing
30 assert_equal 'none', v.sharing
31 end
31 end
32
32
33 def test_invalid_effective_date_validation
33 def test_invalid_effective_date_validation
34 v = Version.new(:project => Project.find(1), :name => '1.1', :effective_date => '99999-01-01')
34 v = Version.new(:project => Project.find(1), :name => '1.1', :effective_date => '99999-01-01')
35 assert !v.valid?
35 assert !v.valid?
36 v.effective_date = '2012-11-33'
36 v.effective_date = '2012-11-33'
37 assert !v.valid?
37 assert !v.valid?
38 v.effective_date = '2012-31-11'
38 v.effective_date = '2012-31-11'
39 assert !v.valid?
39 assert !v.valid?
40 v.effective_date = '-2012-31-11'
41 assert !v.valid?
40 v.effective_date = 'ABC'
42 v.effective_date = 'ABC'
41 assert !v.valid?
43 assert !v.valid?
42 assert_include I18n.translate('activerecord.errors.messages.not_a_date'),
44 assert_include I18n.translate('activerecord.errors.messages.not_a_date'),
43 v.errors[:effective_date]
45 v.errors[:effective_date]
44 end
46 end
45
47
46 def test_progress_should_be_0_with_no_assigned_issues
48 def test_progress_should_be_0_with_no_assigned_issues
47 project = Project.find(1)
49 project = Project.find(1)
48 v = Version.create!(:project => project, :name => 'Progress')
50 v = Version.create!(:project => project, :name => 'Progress')
49 assert_equal 0, v.completed_percent
51 assert_equal 0, v.completed_percent
50 assert_equal 0, v.closed_percent
52 assert_equal 0, v.closed_percent
51 end
53 end
52
54
53 def test_progress_should_be_0_with_unbegun_assigned_issues
55 def test_progress_should_be_0_with_unbegun_assigned_issues
54 project = Project.find(1)
56 project = Project.find(1)
55 v = Version.create!(:project => project, :name => 'Progress')
57 v = Version.create!(:project => project, :name => 'Progress')
56 add_issue(v)
58 add_issue(v)
57 add_issue(v, :done_ratio => 0)
59 add_issue(v, :done_ratio => 0)
58 assert_progress_equal 0, v.completed_percent
60 assert_progress_equal 0, v.completed_percent
59 assert_progress_equal 0, v.closed_percent
61 assert_progress_equal 0, v.closed_percent
60 end
62 end
61
63
62 def test_progress_should_be_100_with_closed_assigned_issues
64 def test_progress_should_be_100_with_closed_assigned_issues
63 project = Project.find(1)
65 project = Project.find(1)
64 status = IssueStatus.where(:is_closed => true).first
66 status = IssueStatus.where(:is_closed => true).first
65 v = Version.create!(:project => project, :name => 'Progress')
67 v = Version.create!(:project => project, :name => 'Progress')
66 add_issue(v, :status => status)
68 add_issue(v, :status => status)
67 add_issue(v, :status => status, :done_ratio => 20)
69 add_issue(v, :status => status, :done_ratio => 20)
68 add_issue(v, :status => status, :done_ratio => 70, :estimated_hours => 25)
70 add_issue(v, :status => status, :done_ratio => 70, :estimated_hours => 25)
69 add_issue(v, :status => status, :estimated_hours => 15)
71 add_issue(v, :status => status, :estimated_hours => 15)
70 assert_progress_equal 100.0, v.completed_percent
72 assert_progress_equal 100.0, v.completed_percent
71 assert_progress_equal 100.0, v.closed_percent
73 assert_progress_equal 100.0, v.closed_percent
72 end
74 end
73
75
74 def test_progress_should_consider_done_ratio_of_open_assigned_issues
76 def test_progress_should_consider_done_ratio_of_open_assigned_issues
75 project = Project.find(1)
77 project = Project.find(1)
76 v = Version.create!(:project => project, :name => 'Progress')
78 v = Version.create!(:project => project, :name => 'Progress')
77 add_issue(v)
79 add_issue(v)
78 add_issue(v, :done_ratio => 20)
80 add_issue(v, :done_ratio => 20)
79 add_issue(v, :done_ratio => 70)
81 add_issue(v, :done_ratio => 70)
80 assert_progress_equal (0.0 + 20.0 + 70.0)/3, v.completed_percent
82 assert_progress_equal (0.0 + 20.0 + 70.0)/3, v.completed_percent
81 assert_progress_equal 0, v.closed_percent
83 assert_progress_equal 0, v.closed_percent
82 end
84 end
83
85
84 def test_progress_should_consider_closed_issues_as_completed
86 def test_progress_should_consider_closed_issues_as_completed
85 project = Project.find(1)
87 project = Project.find(1)
86 v = Version.create!(:project => project, :name => 'Progress')
88 v = Version.create!(:project => project, :name => 'Progress')
87 add_issue(v)
89 add_issue(v)
88 add_issue(v, :done_ratio => 20)
90 add_issue(v, :done_ratio => 20)
89 add_issue(v, :status => IssueStatus.where(:is_closed => true).first)
91 add_issue(v, :status => IssueStatus.where(:is_closed => true).first)
90 assert_progress_equal (0.0 + 20.0 + 100.0)/3, v.completed_percent
92 assert_progress_equal (0.0 + 20.0 + 100.0)/3, v.completed_percent
91 assert_progress_equal (100.0)/3, v.closed_percent
93 assert_progress_equal (100.0)/3, v.closed_percent
92 end
94 end
93
95
94 def test_progress_should_consider_estimated_hours_to_weigth_issues
96 def test_progress_should_consider_estimated_hours_to_weigth_issues
95 project = Project.find(1)
97 project = Project.find(1)
96 v = Version.create!(:project => project, :name => 'Progress')
98 v = Version.create!(:project => project, :name => 'Progress')
97 add_issue(v, :estimated_hours => 10)
99 add_issue(v, :estimated_hours => 10)
98 add_issue(v, :estimated_hours => 20, :done_ratio => 30)
100 add_issue(v, :estimated_hours => 20, :done_ratio => 30)
99 add_issue(v, :estimated_hours => 40, :done_ratio => 10)
101 add_issue(v, :estimated_hours => 40, :done_ratio => 10)
100 add_issue(v, :estimated_hours => 25, :status => IssueStatus.where(:is_closed => true).first)
102 add_issue(v, :estimated_hours => 25, :status => IssueStatus.where(:is_closed => true).first)
101 assert_progress_equal (10.0*0 + 20.0*0.3 + 40*0.1 + 25.0*1)/95.0*100, v.completed_percent
103 assert_progress_equal (10.0*0 + 20.0*0.3 + 40*0.1 + 25.0*1)/95.0*100, v.completed_percent
102 assert_progress_equal 25.0/95.0*100, v.closed_percent
104 assert_progress_equal 25.0/95.0*100, v.closed_percent
103 end
105 end
104
106
105 def test_progress_should_consider_average_estimated_hours_to_weigth_unestimated_issues
107 def test_progress_should_consider_average_estimated_hours_to_weigth_unestimated_issues
106 project = Project.find(1)
108 project = Project.find(1)
107 v = Version.create!(:project => project, :name => 'Progress')
109 v = Version.create!(:project => project, :name => 'Progress')
108 add_issue(v, :done_ratio => 20)
110 add_issue(v, :done_ratio => 20)
109 add_issue(v, :status => IssueStatus.where(:is_closed => true).first)
111 add_issue(v, :status => IssueStatus.where(:is_closed => true).first)
110 add_issue(v, :estimated_hours => 10, :done_ratio => 30)
112 add_issue(v, :estimated_hours => 10, :done_ratio => 30)
111 add_issue(v, :estimated_hours => 40, :done_ratio => 10)
113 add_issue(v, :estimated_hours => 40, :done_ratio => 10)
112 assert_progress_equal (25.0*0.2 + 25.0*1 + 10.0*0.3 + 40.0*0.1)/100.0*100, v.completed_percent
114 assert_progress_equal (25.0*0.2 + 25.0*1 + 10.0*0.3 + 40.0*0.1)/100.0*100, v.completed_percent
113 assert_progress_equal 25.0/100.0*100, v.closed_percent
115 assert_progress_equal 25.0/100.0*100, v.closed_percent
114 end
116 end
115
117
116 def test_should_sort_scheduled_then_unscheduled_versions
118 def test_should_sort_scheduled_then_unscheduled_versions
117 Version.delete_all
119 Version.delete_all
118 v4 = Version.create!(:project_id => 1, :name => 'v4')
120 v4 = Version.create!(:project_id => 1, :name => 'v4')
119 v3 = Version.create!(:project_id => 1, :name => 'v2', :effective_date => '2012-07-14')
121 v3 = Version.create!(:project_id => 1, :name => 'v2', :effective_date => '2012-07-14')
120 v2 = Version.create!(:project_id => 1, :name => 'v1')
122 v2 = Version.create!(:project_id => 1, :name => 'v1')
121 v1 = Version.create!(:project_id => 1, :name => 'v3', :effective_date => '2012-08-02')
123 v1 = Version.create!(:project_id => 1, :name => 'v3', :effective_date => '2012-08-02')
122 v5 = Version.create!(:project_id => 1, :name => 'v5', :effective_date => '2012-07-02')
124 v5 = Version.create!(:project_id => 1, :name => 'v5', :effective_date => '2012-07-02')
123
125
124 assert_equal [v5, v3, v1, v2, v4], [v1, v2, v3, v4, v5].sort
126 assert_equal [v5, v3, v1, v2, v4], [v1, v2, v3, v4, v5].sort
125 assert_equal [v5, v3, v1, v2, v4], Version.sorted.all
127 assert_equal [v5, v3, v1, v2, v4], Version.sorted.all
126 end
128 end
127
129
128 def test_completed_should_be_false_when_due_today
130 def test_completed_should_be_false_when_due_today
129 version = Version.create!(:project_id => 1, :effective_date => Date.today, :name => 'Due today')
131 version = Version.create!(:project_id => 1, :effective_date => Date.today, :name => 'Due today')
130 assert_equal false, version.completed?
132 assert_equal false, version.completed?
131 end
133 end
132
134
133 context "#behind_schedule?" do
135 context "#behind_schedule?" do
134 setup do
136 setup do
135 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
137 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
136 @project = Project.create!(:name => 'test0', :identifier => 'test0')
138 @project = Project.create!(:name => 'test0', :identifier => 'test0')
137 @project.trackers << Tracker.create!(:name => 'track')
139 @project.trackers << Tracker.create!(:name => 'track')
138
140
139 @version = Version.create!(:project => @project, :effective_date => nil, :name => 'version')
141 @version = Version.create!(:project => @project, :effective_date => nil, :name => 'version')
140 end
142 end
141
143
142 should "be false if there are no issues assigned" do
144 should "be false if there are no issues assigned" do
143 @version.update_attribute(:effective_date, Date.yesterday)
145 @version.update_attribute(:effective_date, Date.yesterday)
144 assert_equal false, @version.behind_schedule?
146 assert_equal false, @version.behind_schedule?
145 end
147 end
146
148
147 should "be false if there is no effective_date" do
149 should "be false if there is no effective_date" do
148 assert_equal false, @version.behind_schedule?
150 assert_equal false, @version.behind_schedule?
149 end
151 end
150
152
151 should "be false if all of the issues are ahead of schedule" do
153 should "be false if all of the issues are ahead of schedule" do
152 @version.update_attribute(:effective_date, 7.days.from_now.to_date)
154 @version.update_attribute(:effective_date, 7.days.from_now.to_date)
153 add_issue(@version, :start_date => 7.days.ago, :done_ratio => 60) # 14 day span, 60% done, 50% time left
155 add_issue(@version, :start_date => 7.days.ago, :done_ratio => 60) # 14 day span, 60% done, 50% time left
154 add_issue(@version, :start_date => 7.days.ago, :done_ratio => 60) # 14 day span, 60% done, 50% time left
156 add_issue(@version, :start_date => 7.days.ago, :done_ratio => 60) # 14 day span, 60% done, 50% time left
155 assert_equal 60, @version.completed_percent
157 assert_equal 60, @version.completed_percent
156 assert_equal false, @version.behind_schedule?
158 assert_equal false, @version.behind_schedule?
157 end
159 end
158
160
159 should "be true if any of the issues are behind schedule" do
161 should "be true if any of the issues are behind schedule" do
160 @version.update_attribute(:effective_date, 7.days.from_now.to_date)
162 @version.update_attribute(:effective_date, 7.days.from_now.to_date)
161 add_issue(@version, :start_date => 7.days.ago, :done_ratio => 60) # 14 day span, 60% done, 50% time left
163 add_issue(@version, :start_date => 7.days.ago, :done_ratio => 60) # 14 day span, 60% done, 50% time left
162 add_issue(@version, :start_date => 7.days.ago, :done_ratio => 20) # 14 day span, 20% done, 50% time left
164 add_issue(@version, :start_date => 7.days.ago, :done_ratio => 20) # 14 day span, 20% done, 50% time left
163 assert_equal 40, @version.completed_percent
165 assert_equal 40, @version.completed_percent
164 assert_equal true, @version.behind_schedule?
166 assert_equal true, @version.behind_schedule?
165 end
167 end
166
168
167 should "be false if all of the issues are complete" do
169 should "be false if all of the issues are complete" do
168 @version.update_attribute(:effective_date, 7.days.from_now.to_date)
170 @version.update_attribute(:effective_date, 7.days.from_now.to_date)
169 add_issue(@version, :start_date => 14.days.ago, :done_ratio => 100, :status => IssueStatus.find(5)) # 7 day span
171 add_issue(@version, :start_date => 14.days.ago, :done_ratio => 100, :status => IssueStatus.find(5)) # 7 day span
170 add_issue(@version, :start_date => 14.days.ago, :done_ratio => 100, :status => IssueStatus.find(5)) # 7 day span
172 add_issue(@version, :start_date => 14.days.ago, :done_ratio => 100, :status => IssueStatus.find(5)) # 7 day span
171 assert_equal 100, @version.completed_percent
173 assert_equal 100, @version.completed_percent
172 assert_equal false, @version.behind_schedule?
174 assert_equal false, @version.behind_schedule?
173 end
175 end
174 end
176 end
175
177
176 context "#estimated_hours" do
178 context "#estimated_hours" do
177 setup do
179 setup do
178 @version = Version.create!(:project_id => 1, :name => '#estimated_hours')
180 @version = Version.create!(:project_id => 1, :name => '#estimated_hours')
179 end
181 end
180
182
181 should "return 0 with no assigned issues" do
183 should "return 0 with no assigned issues" do
182 assert_equal 0, @version.estimated_hours
184 assert_equal 0, @version.estimated_hours
183 end
185 end
184
186
185 should "return 0 with no estimated hours" do
187 should "return 0 with no estimated hours" do
186 add_issue(@version)
188 add_issue(@version)
187 assert_equal 0, @version.estimated_hours
189 assert_equal 0, @version.estimated_hours
188 end
190 end
189
191
190 should "return the sum of estimated hours" do
192 should "return the sum of estimated hours" do
191 add_issue(@version, :estimated_hours => 2.5)
193 add_issue(@version, :estimated_hours => 2.5)
192 add_issue(@version, :estimated_hours => 5)
194 add_issue(@version, :estimated_hours => 5)
193 assert_equal 7.5, @version.estimated_hours
195 assert_equal 7.5, @version.estimated_hours
194 end
196 end
195
197
196 should "return the sum of leaves estimated hours" do
198 should "return the sum of leaves estimated hours" do
197 parent = add_issue(@version)
199 parent = add_issue(@version)
198 add_issue(@version, :estimated_hours => 2.5, :parent_issue_id => parent.id)
200 add_issue(@version, :estimated_hours => 2.5, :parent_issue_id => parent.id)
199 add_issue(@version, :estimated_hours => 5, :parent_issue_id => parent.id)
201 add_issue(@version, :estimated_hours => 5, :parent_issue_id => parent.id)
200 assert_equal 7.5, @version.estimated_hours
202 assert_equal 7.5, @version.estimated_hours
201 end
203 end
202 end
204 end
203
205
204 test "should update all issue's fixed_version associations in case the hierarchy changed XXX" do
206 test "should update all issue's fixed_version associations in case the hierarchy changed XXX" do
205 User.current = User.find(1) # Need the admin's permissions
207 User.current = User.find(1) # Need the admin's permissions
206
208
207 @version = Version.find(7)
209 @version = Version.find(7)
208 # Separate hierarchy
210 # Separate hierarchy
209 project_1_issue = Issue.find(1)
211 project_1_issue = Issue.find(1)
210 project_1_issue.fixed_version = @version
212 project_1_issue.fixed_version = @version
211 assert project_1_issue.save, project_1_issue.errors.full_messages.to_s
213 assert project_1_issue.save, project_1_issue.errors.full_messages.to_s
212
214
213 project_5_issue = Issue.find(6)
215 project_5_issue = Issue.find(6)
214 project_5_issue.fixed_version = @version
216 project_5_issue.fixed_version = @version
215 assert project_5_issue.save
217 assert project_5_issue.save
216
218
217 # Project
219 # Project
218 project_2_issue = Issue.find(4)
220 project_2_issue = Issue.find(4)
219 project_2_issue.fixed_version = @version
221 project_2_issue.fixed_version = @version
220 assert project_2_issue.save
222 assert project_2_issue.save
221
223
222 # Update the sharing
224 # Update the sharing
223 @version.sharing = 'none'
225 @version.sharing = 'none'
224 assert @version.save
226 assert @version.save
225
227
226 # Project 1 now out of the shared scope
228 # Project 1 now out of the shared scope
227 project_1_issue.reload
229 project_1_issue.reload
228 assert_equal nil, project_1_issue.fixed_version, "Fixed version is still set after changing the Version's sharing"
230 assert_equal nil, project_1_issue.fixed_version, "Fixed version is still set after changing the Version's sharing"
229
231
230 # Project 5 now out of the shared scope
232 # Project 5 now out of the shared scope
231 project_5_issue.reload
233 project_5_issue.reload
232 assert_equal nil, project_5_issue.fixed_version, "Fixed version is still set after changing the Version's sharing"
234 assert_equal nil, project_5_issue.fixed_version, "Fixed version is still set after changing the Version's sharing"
233
235
234 # Project 2 issue remains
236 # Project 2 issue remains
235 project_2_issue.reload
237 project_2_issue.reload
236 assert_equal @version, project_2_issue.fixed_version
238 assert_equal @version, project_2_issue.fixed_version
237 end
239 end
238
240
239 private
241 private
240
242
241 def add_issue(version, attributes={})
243 def add_issue(version, attributes={})
242 Issue.create!({:project => version.project,
244 Issue.create!({:project => version.project,
243 :fixed_version => version,
245 :fixed_version => version,
244 :subject => 'Test',
246 :subject => 'Test',
245 :author => User.first,
247 :author => User.first,
246 :tracker => version.project.trackers.first}.merge(attributes))
248 :tracker => version.project.trackers.first}.merge(attributes))
247 end
249 end
248
250
249 def assert_progress_equal(expected_float, actual_float, message="")
251 def assert_progress_equal(expected_float, actual_float, message="")
250 assert_in_delta(expected_float, actual_float, 0.000001, message="")
252 assert_in_delta(expected_float, actual_float, 0.000001, message="")
251 end
253 end
252 end
254 end
General Comments 0
You need to be logged in to leave comments. Login now