##// END OF EJS Templates
Support for updating custom fields using the received custom_fields array (#6345, #6403)....
Jean-Philippe Lang -
r4367:3e3315c103f2
parent child
Show More
@@ -1,886 +1,887
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 belongs_to :project
19 belongs_to :project
20 belongs_to :tracker
20 belongs_to :tracker
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27
27
28 has_many :journals, :as => :journalized, :dependent => :destroy
28 has_many :journals, :as => :journalized, :dependent => :destroy
29 has_many :time_entries, :dependent => :delete_all
29 has_many :time_entries, :dependent => :delete_all
30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
31
31
32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
34
34
35 acts_as_nested_set :scope => 'root_id'
35 acts_as_nested_set :scope => 'root_id'
36 acts_as_attachable :after_remove => :attachment_removed
36 acts_as_attachable :after_remove => :attachment_removed
37 acts_as_customizable
37 acts_as_customizable
38 acts_as_watchable
38 acts_as_watchable
39 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
39 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
40 :include => [:project, :journals],
40 :include => [:project, :journals],
41 # sort by id so that limited eager loading doesn't break with postgresql
41 # sort by id so that limited eager loading doesn't break with postgresql
42 :order_column => "#{table_name}.id"
42 :order_column => "#{table_name}.id"
43 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
43 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
44 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
44 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
45 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
45 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
46
46
47 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
47 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
48 :author_key => :author_id
48 :author_key => :author_id
49
49
50 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
50 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
51
51
52 attr_reader :current_journal
52 attr_reader :current_journal
53
53
54 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
54 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
55
55
56 validates_length_of :subject, :maximum => 255
56 validates_length_of :subject, :maximum => 255
57 validates_inclusion_of :done_ratio, :in => 0..100
57 validates_inclusion_of :done_ratio, :in => 0..100
58 validates_numericality_of :estimated_hours, :allow_nil => true
58 validates_numericality_of :estimated_hours, :allow_nil => true
59
59
60 named_scope :visible, lambda {|*args| { :include => :project,
60 named_scope :visible, lambda {|*args| { :include => :project,
61 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
61 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
62
62
63 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
63 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
64
64
65 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
65 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
66 named_scope :with_limit, lambda { |limit| { :limit => limit} }
66 named_scope :with_limit, lambda { |limit| { :limit => limit} }
67 named_scope :on_active_project, :include => [:status, :project, :tracker],
67 named_scope :on_active_project, :include => [:status, :project, :tracker],
68 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
68 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
69 named_scope :for_gantt, lambda {
69 named_scope :for_gantt, lambda {
70 {
70 {
71 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version]
71 :include => [:tracker, :status, :assigned_to, :priority, :project, :fixed_version]
72 }
72 }
73 }
73 }
74
74
75 named_scope :without_version, lambda {
75 named_scope :without_version, lambda {
76 {
76 {
77 :conditions => { :fixed_version_id => nil}
77 :conditions => { :fixed_version_id => nil}
78 }
78 }
79 }
79 }
80
80
81 named_scope :with_query, lambda {|query|
81 named_scope :with_query, lambda {|query|
82 {
82 {
83 :conditions => Query.merge_conditions(query.statement)
83 :conditions => Query.merge_conditions(query.statement)
84 }
84 }
85 }
85 }
86
86
87 before_create :default_assign
87 before_create :default_assign
88 before_save :close_duplicates, :update_done_ratio_from_issue_status
88 before_save :close_duplicates, :update_done_ratio_from_issue_status
89 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
89 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
90 after_destroy :destroy_children
90 after_destroy :destroy_children
91 after_destroy :update_parent_attributes
91 after_destroy :update_parent_attributes
92
92
93 # Returns true if usr or current user is allowed to view the issue
93 # Returns true if usr or current user is allowed to view the issue
94 def visible?(usr=nil)
94 def visible?(usr=nil)
95 (usr || User.current).allowed_to?(:view_issues, self.project)
95 (usr || User.current).allowed_to?(:view_issues, self.project)
96 end
96 end
97
97
98 def after_initialize
98 def after_initialize
99 if new_record?
99 if new_record?
100 # set default values for new records only
100 # set default values for new records only
101 self.status ||= IssueStatus.default
101 self.status ||= IssueStatus.default
102 self.priority ||= IssuePriority.default
102 self.priority ||= IssuePriority.default
103 end
103 end
104 end
104 end
105
105
106 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
106 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
107 def available_custom_fields
107 def available_custom_fields
108 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
108 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
109 end
109 end
110
110
111 def copy_from(arg)
111 def copy_from(arg)
112 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
112 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
113 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
113 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
114 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
114 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
115 self.status = issue.status
115 self.status = issue.status
116 self
116 self
117 end
117 end
118
118
119 # Moves/copies an issue to a new project and tracker
119 # Moves/copies an issue to a new project and tracker
120 # Returns the moved/copied issue on success, false on failure
120 # Returns the moved/copied issue on success, false on failure
121 def move_to_project(*args)
121 def move_to_project(*args)
122 ret = Issue.transaction do
122 ret = Issue.transaction do
123 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
123 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
124 end || false
124 end || false
125 end
125 end
126
126
127 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
127 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
128 options ||= {}
128 options ||= {}
129 issue = options[:copy] ? self.class.new.copy_from(self) : self
129 issue = options[:copy] ? self.class.new.copy_from(self) : self
130
130
131 if new_project && issue.project_id != new_project.id
131 if new_project && issue.project_id != new_project.id
132 # delete issue relations
132 # delete issue relations
133 unless Setting.cross_project_issue_relations?
133 unless Setting.cross_project_issue_relations?
134 issue.relations_from.clear
134 issue.relations_from.clear
135 issue.relations_to.clear
135 issue.relations_to.clear
136 end
136 end
137 # issue is moved to another project
137 # issue is moved to another project
138 # reassign to the category with same name if any
138 # reassign to the category with same name if any
139 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
139 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
140 issue.category = new_category
140 issue.category = new_category
141 # Keep the fixed_version if it's still valid in the new_project
141 # Keep the fixed_version if it's still valid in the new_project
142 unless new_project.shared_versions.include?(issue.fixed_version)
142 unless new_project.shared_versions.include?(issue.fixed_version)
143 issue.fixed_version = nil
143 issue.fixed_version = nil
144 end
144 end
145 issue.project = new_project
145 issue.project = new_project
146 if issue.parent && issue.parent.project_id != issue.project_id
146 if issue.parent && issue.parent.project_id != issue.project_id
147 issue.parent_issue_id = nil
147 issue.parent_issue_id = nil
148 end
148 end
149 end
149 end
150 if new_tracker
150 if new_tracker
151 issue.tracker = new_tracker
151 issue.tracker = new_tracker
152 issue.reset_custom_values!
152 issue.reset_custom_values!
153 end
153 end
154 if options[:copy]
154 if options[:copy]
155 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
155 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
156 issue.status = if options[:attributes] && options[:attributes][:status_id]
156 issue.status = if options[:attributes] && options[:attributes][:status_id]
157 IssueStatus.find_by_id(options[:attributes][:status_id])
157 IssueStatus.find_by_id(options[:attributes][:status_id])
158 else
158 else
159 self.status
159 self.status
160 end
160 end
161 end
161 end
162 # Allow bulk setting of attributes on the issue
162 # Allow bulk setting of attributes on the issue
163 if options[:attributes]
163 if options[:attributes]
164 issue.attributes = options[:attributes]
164 issue.attributes = options[:attributes]
165 end
165 end
166 if issue.save
166 if issue.save
167 unless options[:copy]
167 unless options[:copy]
168 # Manually update project_id on related time entries
168 # Manually update project_id on related time entries
169 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
169 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
170
170
171 issue.children.each do |child|
171 issue.children.each do |child|
172 unless child.move_to_project_without_transaction(new_project)
172 unless child.move_to_project_without_transaction(new_project)
173 # Move failed and transaction was rollback'd
173 # Move failed and transaction was rollback'd
174 return false
174 return false
175 end
175 end
176 end
176 end
177 end
177 end
178 else
178 else
179 return false
179 return false
180 end
180 end
181 issue
181 issue
182 end
182 end
183
183
184 def status_id=(sid)
184 def status_id=(sid)
185 self.status = nil
185 self.status = nil
186 write_attribute(:status_id, sid)
186 write_attribute(:status_id, sid)
187 end
187 end
188
188
189 def priority_id=(pid)
189 def priority_id=(pid)
190 self.priority = nil
190 self.priority = nil
191 write_attribute(:priority_id, pid)
191 write_attribute(:priority_id, pid)
192 end
192 end
193
193
194 def tracker_id=(tid)
194 def tracker_id=(tid)
195 self.tracker = nil
195 self.tracker = nil
196 result = write_attribute(:tracker_id, tid)
196 result = write_attribute(:tracker_id, tid)
197 @custom_field_values = nil
197 @custom_field_values = nil
198 result
198 result
199 end
199 end
200
200
201 # Overrides attributes= so that tracker_id gets assigned first
201 # Overrides attributes= so that tracker_id gets assigned first
202 def attributes_with_tracker_first=(new_attributes, *args)
202 def attributes_with_tracker_first=(new_attributes, *args)
203 return if new_attributes.nil?
203 return if new_attributes.nil?
204 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
204 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
205 if new_tracker_id
205 if new_tracker_id
206 self.tracker_id = new_tracker_id
206 self.tracker_id = new_tracker_id
207 end
207 end
208 send :attributes_without_tracker_first=, new_attributes, *args
208 send :attributes_without_tracker_first=, new_attributes, *args
209 end
209 end
210 # Do not redefine alias chain on reload (see #4838)
210 # Do not redefine alias chain on reload (see #4838)
211 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
211 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
212
212
213 def estimated_hours=(h)
213 def estimated_hours=(h)
214 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
214 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
215 end
215 end
216
216
217 SAFE_ATTRIBUTES = %w(
217 SAFE_ATTRIBUTES = %w(
218 tracker_id
218 tracker_id
219 status_id
219 status_id
220 parent_issue_id
220 parent_issue_id
221 category_id
221 category_id
222 assigned_to_id
222 assigned_to_id
223 priority_id
223 priority_id
224 fixed_version_id
224 fixed_version_id
225 subject
225 subject
226 description
226 description
227 start_date
227 start_date
228 due_date
228 due_date
229 done_ratio
229 done_ratio
230 estimated_hours
230 estimated_hours
231 custom_field_values
231 custom_field_values
232 custom_fields
232 lock_version
233 lock_version
233 ) unless const_defined?(:SAFE_ATTRIBUTES)
234 ) unless const_defined?(:SAFE_ATTRIBUTES)
234
235
235 SAFE_ATTRIBUTES_ON_TRANSITION = %w(
236 SAFE_ATTRIBUTES_ON_TRANSITION = %w(
236 status_id
237 status_id
237 assigned_to_id
238 assigned_to_id
238 fixed_version_id
239 fixed_version_id
239 done_ratio
240 done_ratio
240 ) unless const_defined?(:SAFE_ATTRIBUTES_ON_TRANSITION)
241 ) unless const_defined?(:SAFE_ATTRIBUTES_ON_TRANSITION)
241
242
242 # Safely sets attributes
243 # Safely sets attributes
243 # Should be called from controllers instead of #attributes=
244 # Should be called from controllers instead of #attributes=
244 # attr_accessible is too rough because we still want things like
245 # attr_accessible is too rough because we still want things like
245 # Issue.new(:project => foo) to work
246 # Issue.new(:project => foo) to work
246 # TODO: move workflow/permission checks from controllers to here
247 # TODO: move workflow/permission checks from controllers to here
247 def safe_attributes=(attrs, user=User.current)
248 def safe_attributes=(attrs, user=User.current)
248 return unless attrs.is_a?(Hash)
249 return unless attrs.is_a?(Hash)
249
250
250 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
251 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
251 if new_record? || user.allowed_to?(:edit_issues, project)
252 if new_record? || user.allowed_to?(:edit_issues, project)
252 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
253 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
253 elsif new_statuses_allowed_to(user).any?
254 elsif new_statuses_allowed_to(user).any?
254 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES_ON_TRANSITION.include?(k)}
255 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES_ON_TRANSITION.include?(k)}
255 else
256 else
256 return
257 return
257 end
258 end
258
259
259 # Tracker must be set before since new_statuses_allowed_to depends on it.
260 # Tracker must be set before since new_statuses_allowed_to depends on it.
260 if t = attrs.delete('tracker_id')
261 if t = attrs.delete('tracker_id')
261 self.tracker_id = t
262 self.tracker_id = t
262 end
263 end
263
264
264 if attrs['status_id']
265 if attrs['status_id']
265 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
266 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
266 attrs.delete('status_id')
267 attrs.delete('status_id')
267 end
268 end
268 end
269 end
269
270
270 unless leaf?
271 unless leaf?
271 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
272 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
272 end
273 end
273
274
274 if attrs.has_key?('parent_issue_id')
275 if attrs.has_key?('parent_issue_id')
275 if !user.allowed_to?(:manage_subtasks, project)
276 if !user.allowed_to?(:manage_subtasks, project)
276 attrs.delete('parent_issue_id')
277 attrs.delete('parent_issue_id')
277 elsif !attrs['parent_issue_id'].blank?
278 elsif !attrs['parent_issue_id'].blank?
278 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
279 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
279 end
280 end
280 end
281 end
281
282
282 self.attributes = attrs
283 self.attributes = attrs
283 end
284 end
284
285
285 def done_ratio
286 def done_ratio
286 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
287 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
287 status.default_done_ratio
288 status.default_done_ratio
288 else
289 else
289 read_attribute(:done_ratio)
290 read_attribute(:done_ratio)
290 end
291 end
291 end
292 end
292
293
293 def self.use_status_for_done_ratio?
294 def self.use_status_for_done_ratio?
294 Setting.issue_done_ratio == 'issue_status'
295 Setting.issue_done_ratio == 'issue_status'
295 end
296 end
296
297
297 def self.use_field_for_done_ratio?
298 def self.use_field_for_done_ratio?
298 Setting.issue_done_ratio == 'issue_field'
299 Setting.issue_done_ratio == 'issue_field'
299 end
300 end
300
301
301 def validate
302 def validate
302 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
303 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
303 errors.add :due_date, :not_a_date
304 errors.add :due_date, :not_a_date
304 end
305 end
305
306
306 if self.due_date and self.start_date and self.due_date < self.start_date
307 if self.due_date and self.start_date and self.due_date < self.start_date
307 errors.add :due_date, :greater_than_start_date
308 errors.add :due_date, :greater_than_start_date
308 end
309 end
309
310
310 if start_date && soonest_start && start_date < soonest_start
311 if start_date && soonest_start && start_date < soonest_start
311 errors.add :start_date, :invalid
312 errors.add :start_date, :invalid
312 end
313 end
313
314
314 if fixed_version
315 if fixed_version
315 if !assignable_versions.include?(fixed_version)
316 if !assignable_versions.include?(fixed_version)
316 errors.add :fixed_version_id, :inclusion
317 errors.add :fixed_version_id, :inclusion
317 elsif reopened? && fixed_version.closed?
318 elsif reopened? && fixed_version.closed?
318 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
319 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
319 end
320 end
320 end
321 end
321
322
322 # Checks that the issue can not be added/moved to a disabled tracker
323 # Checks that the issue can not be added/moved to a disabled tracker
323 if project && (tracker_id_changed? || project_id_changed?)
324 if project && (tracker_id_changed? || project_id_changed?)
324 unless project.trackers.include?(tracker)
325 unless project.trackers.include?(tracker)
325 errors.add :tracker_id, :inclusion
326 errors.add :tracker_id, :inclusion
326 end
327 end
327 end
328 end
328
329
329 # Checks parent issue assignment
330 # Checks parent issue assignment
330 if @parent_issue
331 if @parent_issue
331 if @parent_issue.project_id != project_id
332 if @parent_issue.project_id != project_id
332 errors.add :parent_issue_id, :not_same_project
333 errors.add :parent_issue_id, :not_same_project
333 elsif !new_record?
334 elsif !new_record?
334 # moving an existing issue
335 # moving an existing issue
335 if @parent_issue.root_id != root_id
336 if @parent_issue.root_id != root_id
336 # we can always move to another tree
337 # we can always move to another tree
337 elsif move_possible?(@parent_issue)
338 elsif move_possible?(@parent_issue)
338 # move accepted inside tree
339 # move accepted inside tree
339 else
340 else
340 errors.add :parent_issue_id, :not_a_valid_parent
341 errors.add :parent_issue_id, :not_a_valid_parent
341 end
342 end
342 end
343 end
343 end
344 end
344 end
345 end
345
346
346 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
347 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
347 # even if the user turns off the setting later
348 # even if the user turns off the setting later
348 def update_done_ratio_from_issue_status
349 def update_done_ratio_from_issue_status
349 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
350 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
350 self.done_ratio = status.default_done_ratio
351 self.done_ratio = status.default_done_ratio
351 end
352 end
352 end
353 end
353
354
354 def init_journal(user, notes = "")
355 def init_journal(user, notes = "")
355 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
356 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
356 @issue_before_change = self.clone
357 @issue_before_change = self.clone
357 @issue_before_change.status = self.status
358 @issue_before_change.status = self.status
358 @custom_values_before_change = {}
359 @custom_values_before_change = {}
359 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
360 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
360 # Make sure updated_on is updated when adding a note.
361 # Make sure updated_on is updated when adding a note.
361 updated_on_will_change!
362 updated_on_will_change!
362 @current_journal
363 @current_journal
363 end
364 end
364
365
365 # Return true if the issue is closed, otherwise false
366 # Return true if the issue is closed, otherwise false
366 def closed?
367 def closed?
367 self.status.is_closed?
368 self.status.is_closed?
368 end
369 end
369
370
370 # Return true if the issue is being reopened
371 # Return true if the issue is being reopened
371 def reopened?
372 def reopened?
372 if !new_record? && status_id_changed?
373 if !new_record? && status_id_changed?
373 status_was = IssueStatus.find_by_id(status_id_was)
374 status_was = IssueStatus.find_by_id(status_id_was)
374 status_new = IssueStatus.find_by_id(status_id)
375 status_new = IssueStatus.find_by_id(status_id)
375 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
376 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
376 return true
377 return true
377 end
378 end
378 end
379 end
379 false
380 false
380 end
381 end
381
382
382 # Return true if the issue is being closed
383 # Return true if the issue is being closed
383 def closing?
384 def closing?
384 if !new_record? && status_id_changed?
385 if !new_record? && status_id_changed?
385 status_was = IssueStatus.find_by_id(status_id_was)
386 status_was = IssueStatus.find_by_id(status_id_was)
386 status_new = IssueStatus.find_by_id(status_id)
387 status_new = IssueStatus.find_by_id(status_id)
387 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
388 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
388 return true
389 return true
389 end
390 end
390 end
391 end
391 false
392 false
392 end
393 end
393
394
394 # Returns true if the issue is overdue
395 # Returns true if the issue is overdue
395 def overdue?
396 def overdue?
396 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
397 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
397 end
398 end
398
399
399 # Is the amount of work done less than it should for the due date
400 # Is the amount of work done less than it should for the due date
400 def behind_schedule?
401 def behind_schedule?
401 return false if start_date.nil? || due_date.nil?
402 return false if start_date.nil? || due_date.nil?
402 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
403 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
403 return done_date <= Date.today
404 return done_date <= Date.today
404 end
405 end
405
406
406 # Does this issue have children?
407 # Does this issue have children?
407 def children?
408 def children?
408 !leaf?
409 !leaf?
409 end
410 end
410
411
411 # Users the issue can be assigned to
412 # Users the issue can be assigned to
412 def assignable_users
413 def assignable_users
413 users = project.assignable_users
414 users = project.assignable_users
414 users << author if author
415 users << author if author
415 users.uniq.sort
416 users.uniq.sort
416 end
417 end
417
418
418 # Versions that the issue can be assigned to
419 # Versions that the issue can be assigned to
419 def assignable_versions
420 def assignable_versions
420 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
421 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
421 end
422 end
422
423
423 # Returns true if this issue is blocked by another issue that is still open
424 # Returns true if this issue is blocked by another issue that is still open
424 def blocked?
425 def blocked?
425 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
426 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
426 end
427 end
427
428
428 # Returns an array of status that user is able to apply
429 # Returns an array of status that user is able to apply
429 def new_statuses_allowed_to(user, include_default=false)
430 def new_statuses_allowed_to(user, include_default=false)
430 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
431 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
431 statuses << status unless statuses.empty?
432 statuses << status unless statuses.empty?
432 statuses << IssueStatus.default if include_default
433 statuses << IssueStatus.default if include_default
433 statuses = statuses.uniq.sort
434 statuses = statuses.uniq.sort
434 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
435 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
435 end
436 end
436
437
437 # Returns the mail adresses of users that should be notified
438 # Returns the mail adresses of users that should be notified
438 def recipients
439 def recipients
439 notified = project.notified_users
440 notified = project.notified_users
440 # Author and assignee are always notified unless they have been
441 # Author and assignee are always notified unless they have been
441 # locked or don't want to be notified
442 # locked or don't want to be notified
442 notified << author if author && author.active? && author.notify_about?(self)
443 notified << author if author && author.active? && author.notify_about?(self)
443 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
444 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
444 notified.uniq!
445 notified.uniq!
445 # Remove users that can not view the issue
446 # Remove users that can not view the issue
446 notified.reject! {|user| !visible?(user)}
447 notified.reject! {|user| !visible?(user)}
447 notified.collect(&:mail)
448 notified.collect(&:mail)
448 end
449 end
449
450
450 # Returns the total number of hours spent on this issue and its descendants
451 # Returns the total number of hours spent on this issue and its descendants
451 #
452 #
452 # Example:
453 # Example:
453 # spent_hours => 0.0
454 # spent_hours => 0.0
454 # spent_hours => 50.2
455 # spent_hours => 50.2
455 def spent_hours
456 def spent_hours
456 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
457 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
457 end
458 end
458
459
459 def relations
460 def relations
460 (relations_from + relations_to).sort
461 (relations_from + relations_to).sort
461 end
462 end
462
463
463 def all_dependent_issues
464 def all_dependent_issues
464 dependencies = []
465 dependencies = []
465 relations_from.each do |relation|
466 relations_from.each do |relation|
466 dependencies << relation.issue_to
467 dependencies << relation.issue_to
467 dependencies += relation.issue_to.all_dependent_issues
468 dependencies += relation.issue_to.all_dependent_issues
468 end
469 end
469 dependencies
470 dependencies
470 end
471 end
471
472
472 # Returns an array of issues that duplicate this one
473 # Returns an array of issues that duplicate this one
473 def duplicates
474 def duplicates
474 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
475 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
475 end
476 end
476
477
477 # Returns the due date or the target due date if any
478 # Returns the due date or the target due date if any
478 # Used on gantt chart
479 # Used on gantt chart
479 def due_before
480 def due_before
480 due_date || (fixed_version ? fixed_version.effective_date : nil)
481 due_date || (fixed_version ? fixed_version.effective_date : nil)
481 end
482 end
482
483
483 # Returns the time scheduled for this issue.
484 # Returns the time scheduled for this issue.
484 #
485 #
485 # Example:
486 # Example:
486 # Start Date: 2/26/09, End Date: 3/04/09
487 # Start Date: 2/26/09, End Date: 3/04/09
487 # duration => 6
488 # duration => 6
488 def duration
489 def duration
489 (start_date && due_date) ? due_date - start_date : 0
490 (start_date && due_date) ? due_date - start_date : 0
490 end
491 end
491
492
492 def soonest_start
493 def soonest_start
493 @soonest_start ||= (
494 @soonest_start ||= (
494 relations_to.collect{|relation| relation.successor_soonest_start} +
495 relations_to.collect{|relation| relation.successor_soonest_start} +
495 ancestors.collect(&:soonest_start)
496 ancestors.collect(&:soonest_start)
496 ).compact.max
497 ).compact.max
497 end
498 end
498
499
499 def reschedule_after(date)
500 def reschedule_after(date)
500 return if date.nil?
501 return if date.nil?
501 if leaf?
502 if leaf?
502 if start_date.nil? || start_date < date
503 if start_date.nil? || start_date < date
503 self.start_date, self.due_date = date, date + duration
504 self.start_date, self.due_date = date, date + duration
504 save
505 save
505 end
506 end
506 else
507 else
507 leaves.each do |leaf|
508 leaves.each do |leaf|
508 leaf.reschedule_after(date)
509 leaf.reschedule_after(date)
509 end
510 end
510 end
511 end
511 end
512 end
512
513
513 def <=>(issue)
514 def <=>(issue)
514 if issue.nil?
515 if issue.nil?
515 -1
516 -1
516 elsif root_id != issue.root_id
517 elsif root_id != issue.root_id
517 (root_id || 0) <=> (issue.root_id || 0)
518 (root_id || 0) <=> (issue.root_id || 0)
518 else
519 else
519 (lft || 0) <=> (issue.lft || 0)
520 (lft || 0) <=> (issue.lft || 0)
520 end
521 end
521 end
522 end
522
523
523 def to_s
524 def to_s
524 "#{tracker} ##{id}: #{subject}"
525 "#{tracker} ##{id}: #{subject}"
525 end
526 end
526
527
527 # Returns a string of css classes that apply to the issue
528 # Returns a string of css classes that apply to the issue
528 def css_classes
529 def css_classes
529 s = "issue status-#{status.position} priority-#{priority.position}"
530 s = "issue status-#{status.position} priority-#{priority.position}"
530 s << ' closed' if closed?
531 s << ' closed' if closed?
531 s << ' overdue' if overdue?
532 s << ' overdue' if overdue?
532 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
533 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
533 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
534 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
534 s
535 s
535 end
536 end
536
537
537 # Saves an issue, time_entry, attachments, and a journal from the parameters
538 # Saves an issue, time_entry, attachments, and a journal from the parameters
538 # Returns false if save fails
539 # Returns false if save fails
539 def save_issue_with_child_records(params, existing_time_entry=nil)
540 def save_issue_with_child_records(params, existing_time_entry=nil)
540 Issue.transaction do
541 Issue.transaction do
541 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
542 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
542 @time_entry = existing_time_entry || TimeEntry.new
543 @time_entry = existing_time_entry || TimeEntry.new
543 @time_entry.project = project
544 @time_entry.project = project
544 @time_entry.issue = self
545 @time_entry.issue = self
545 @time_entry.user = User.current
546 @time_entry.user = User.current
546 @time_entry.spent_on = Date.today
547 @time_entry.spent_on = Date.today
547 @time_entry.attributes = params[:time_entry]
548 @time_entry.attributes = params[:time_entry]
548 self.time_entries << @time_entry
549 self.time_entries << @time_entry
549 end
550 end
550
551
551 if valid?
552 if valid?
552 attachments = Attachment.attach_files(self, params[:attachments])
553 attachments = Attachment.attach_files(self, params[:attachments])
553
554
554 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
555 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
555 # TODO: Rename hook
556 # TODO: Rename hook
556 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
557 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
557 begin
558 begin
558 if save
559 if save
559 # TODO: Rename hook
560 # TODO: Rename hook
560 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
561 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
561 else
562 else
562 raise ActiveRecord::Rollback
563 raise ActiveRecord::Rollback
563 end
564 end
564 rescue ActiveRecord::StaleObjectError
565 rescue ActiveRecord::StaleObjectError
565 attachments[:files].each(&:destroy)
566 attachments[:files].each(&:destroy)
566 errors.add_to_base l(:notice_locking_conflict)
567 errors.add_to_base l(:notice_locking_conflict)
567 raise ActiveRecord::Rollback
568 raise ActiveRecord::Rollback
568 end
569 end
569 end
570 end
570 end
571 end
571 end
572 end
572
573
573 # Unassigns issues from +version+ if it's no longer shared with issue's project
574 # Unassigns issues from +version+ if it's no longer shared with issue's project
574 def self.update_versions_from_sharing_change(version)
575 def self.update_versions_from_sharing_change(version)
575 # Update issues assigned to the version
576 # Update issues assigned to the version
576 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
577 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
577 end
578 end
578
579
579 # Unassigns issues from versions that are no longer shared
580 # Unassigns issues from versions that are no longer shared
580 # after +project+ was moved
581 # after +project+ was moved
581 def self.update_versions_from_hierarchy_change(project)
582 def self.update_versions_from_hierarchy_change(project)
582 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
583 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
583 # Update issues of the moved projects and issues assigned to a version of a moved project
584 # Update issues of the moved projects and issues assigned to a version of a moved project
584 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
585 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
585 end
586 end
586
587
587 def parent_issue_id=(arg)
588 def parent_issue_id=(arg)
588 parent_issue_id = arg.blank? ? nil : arg.to_i
589 parent_issue_id = arg.blank? ? nil : arg.to_i
589 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
590 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
590 @parent_issue.id
591 @parent_issue.id
591 else
592 else
592 @parent_issue = nil
593 @parent_issue = nil
593 nil
594 nil
594 end
595 end
595 end
596 end
596
597
597 def parent_issue_id
598 def parent_issue_id
598 if instance_variable_defined? :@parent_issue
599 if instance_variable_defined? :@parent_issue
599 @parent_issue.nil? ? nil : @parent_issue.id
600 @parent_issue.nil? ? nil : @parent_issue.id
600 else
601 else
601 parent_id
602 parent_id
602 end
603 end
603 end
604 end
604
605
605 # Extracted from the ReportsController.
606 # Extracted from the ReportsController.
606 def self.by_tracker(project)
607 def self.by_tracker(project)
607 count_and_group_by(:project => project,
608 count_and_group_by(:project => project,
608 :field => 'tracker_id',
609 :field => 'tracker_id',
609 :joins => Tracker.table_name)
610 :joins => Tracker.table_name)
610 end
611 end
611
612
612 def self.by_version(project)
613 def self.by_version(project)
613 count_and_group_by(:project => project,
614 count_and_group_by(:project => project,
614 :field => 'fixed_version_id',
615 :field => 'fixed_version_id',
615 :joins => Version.table_name)
616 :joins => Version.table_name)
616 end
617 end
617
618
618 def self.by_priority(project)
619 def self.by_priority(project)
619 count_and_group_by(:project => project,
620 count_and_group_by(:project => project,
620 :field => 'priority_id',
621 :field => 'priority_id',
621 :joins => IssuePriority.table_name)
622 :joins => IssuePriority.table_name)
622 end
623 end
623
624
624 def self.by_category(project)
625 def self.by_category(project)
625 count_and_group_by(:project => project,
626 count_and_group_by(:project => project,
626 :field => 'category_id',
627 :field => 'category_id',
627 :joins => IssueCategory.table_name)
628 :joins => IssueCategory.table_name)
628 end
629 end
629
630
630 def self.by_assigned_to(project)
631 def self.by_assigned_to(project)
631 count_and_group_by(:project => project,
632 count_and_group_by(:project => project,
632 :field => 'assigned_to_id',
633 :field => 'assigned_to_id',
633 :joins => User.table_name)
634 :joins => User.table_name)
634 end
635 end
635
636
636 def self.by_author(project)
637 def self.by_author(project)
637 count_and_group_by(:project => project,
638 count_and_group_by(:project => project,
638 :field => 'author_id',
639 :field => 'author_id',
639 :joins => User.table_name)
640 :joins => User.table_name)
640 end
641 end
641
642
642 def self.by_subproject(project)
643 def self.by_subproject(project)
643 ActiveRecord::Base.connection.select_all("select s.id as status_id,
644 ActiveRecord::Base.connection.select_all("select s.id as status_id,
644 s.is_closed as closed,
645 s.is_closed as closed,
645 i.project_id as project_id,
646 i.project_id as project_id,
646 count(i.id) as total
647 count(i.id) as total
647 from
648 from
648 #{Issue.table_name} i, #{IssueStatus.table_name} s
649 #{Issue.table_name} i, #{IssueStatus.table_name} s
649 where
650 where
650 i.status_id=s.id
651 i.status_id=s.id
651 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
652 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
652 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
653 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
653 end
654 end
654 # End ReportsController extraction
655 # End ReportsController extraction
655
656
656 # Returns an array of projects that current user can move issues to
657 # Returns an array of projects that current user can move issues to
657 def self.allowed_target_projects_on_move
658 def self.allowed_target_projects_on_move
658 projects = []
659 projects = []
659 if User.current.admin?
660 if User.current.admin?
660 # admin is allowed to move issues to any active (visible) project
661 # admin is allowed to move issues to any active (visible) project
661 projects = Project.visible.all
662 projects = Project.visible.all
662 elsif User.current.logged?
663 elsif User.current.logged?
663 if Role.non_member.allowed_to?(:move_issues)
664 if Role.non_member.allowed_to?(:move_issues)
664 projects = Project.visible.all
665 projects = Project.visible.all
665 else
666 else
666 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
667 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
667 end
668 end
668 end
669 end
669 projects
670 projects
670 end
671 end
671
672
672 private
673 private
673
674
674 def update_nested_set_attributes
675 def update_nested_set_attributes
675 if root_id.nil?
676 if root_id.nil?
676 # issue was just created
677 # issue was just created
677 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
678 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
678 set_default_left_and_right
679 set_default_left_and_right
679 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
680 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
680 if @parent_issue
681 if @parent_issue
681 move_to_child_of(@parent_issue)
682 move_to_child_of(@parent_issue)
682 end
683 end
683 reload
684 reload
684 elsif parent_issue_id != parent_id
685 elsif parent_issue_id != parent_id
685 former_parent_id = parent_id
686 former_parent_id = parent_id
686 # moving an existing issue
687 # moving an existing issue
687 if @parent_issue && @parent_issue.root_id == root_id
688 if @parent_issue && @parent_issue.root_id == root_id
688 # inside the same tree
689 # inside the same tree
689 move_to_child_of(@parent_issue)
690 move_to_child_of(@parent_issue)
690 else
691 else
691 # to another tree
692 # to another tree
692 unless root?
693 unless root?
693 move_to_right_of(root)
694 move_to_right_of(root)
694 reload
695 reload
695 end
696 end
696 old_root_id = root_id
697 old_root_id = root_id
697 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
698 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
698 target_maxright = nested_set_scope.maximum(right_column_name) || 0
699 target_maxright = nested_set_scope.maximum(right_column_name) || 0
699 offset = target_maxright + 1 - lft
700 offset = target_maxright + 1 - lft
700 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
701 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
701 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
702 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
702 self[left_column_name] = lft + offset
703 self[left_column_name] = lft + offset
703 self[right_column_name] = rgt + offset
704 self[right_column_name] = rgt + offset
704 if @parent_issue
705 if @parent_issue
705 move_to_child_of(@parent_issue)
706 move_to_child_of(@parent_issue)
706 end
707 end
707 end
708 end
708 reload
709 reload
709 # delete invalid relations of all descendants
710 # delete invalid relations of all descendants
710 self_and_descendants.each do |issue|
711 self_and_descendants.each do |issue|
711 issue.relations.each do |relation|
712 issue.relations.each do |relation|
712 relation.destroy unless relation.valid?
713 relation.destroy unless relation.valid?
713 end
714 end
714 end
715 end
715 # update former parent
716 # update former parent
716 recalculate_attributes_for(former_parent_id) if former_parent_id
717 recalculate_attributes_for(former_parent_id) if former_parent_id
717 end
718 end
718 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
719 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
719 end
720 end
720
721
721 def update_parent_attributes
722 def update_parent_attributes
722 recalculate_attributes_for(parent_id) if parent_id
723 recalculate_attributes_for(parent_id) if parent_id
723 end
724 end
724
725
725 def recalculate_attributes_for(issue_id)
726 def recalculate_attributes_for(issue_id)
726 if issue_id && p = Issue.find_by_id(issue_id)
727 if issue_id && p = Issue.find_by_id(issue_id)
727 # priority = highest priority of children
728 # priority = highest priority of children
728 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
729 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
729 p.priority = IssuePriority.find_by_position(priority_position)
730 p.priority = IssuePriority.find_by_position(priority_position)
730 end
731 end
731
732
732 # start/due dates = lowest/highest dates of children
733 # start/due dates = lowest/highest dates of children
733 p.start_date = p.children.minimum(:start_date)
734 p.start_date = p.children.minimum(:start_date)
734 p.due_date = p.children.maximum(:due_date)
735 p.due_date = p.children.maximum(:due_date)
735 if p.start_date && p.due_date && p.due_date < p.start_date
736 if p.start_date && p.due_date && p.due_date < p.start_date
736 p.start_date, p.due_date = p.due_date, p.start_date
737 p.start_date, p.due_date = p.due_date, p.start_date
737 end
738 end
738
739
739 # done ratio = weighted average ratio of leaves
740 # done ratio = weighted average ratio of leaves
740 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
741 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
741 leaves_count = p.leaves.count
742 leaves_count = p.leaves.count
742 if leaves_count > 0
743 if leaves_count > 0
743 average = p.leaves.average(:estimated_hours).to_f
744 average = p.leaves.average(:estimated_hours).to_f
744 if average == 0
745 if average == 0
745 average = 1
746 average = 1
746 end
747 end
747 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
748 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
748 progress = done / (average * leaves_count)
749 progress = done / (average * leaves_count)
749 p.done_ratio = progress.round
750 p.done_ratio = progress.round
750 end
751 end
751 end
752 end
752
753
753 # estimate = sum of leaves estimates
754 # estimate = sum of leaves estimates
754 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
755 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
755 p.estimated_hours = nil if p.estimated_hours == 0.0
756 p.estimated_hours = nil if p.estimated_hours == 0.0
756
757
757 # ancestors will be recursively updated
758 # ancestors will be recursively updated
758 p.save(false)
759 p.save(false)
759 end
760 end
760 end
761 end
761
762
762 def destroy_children
763 def destroy_children
763 unless leaf?
764 unless leaf?
764 children.each do |child|
765 children.each do |child|
765 child.destroy
766 child.destroy
766 end
767 end
767 end
768 end
768 end
769 end
769
770
770 # Update issues so their versions are not pointing to a
771 # Update issues so their versions are not pointing to a
771 # fixed_version that is not shared with the issue's project
772 # fixed_version that is not shared with the issue's project
772 def self.update_versions(conditions=nil)
773 def self.update_versions(conditions=nil)
773 # Only need to update issues with a fixed_version from
774 # Only need to update issues with a fixed_version from
774 # a different project and that is not systemwide shared
775 # a different project and that is not systemwide shared
775 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
776 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
776 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
777 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
777 " AND #{Version.table_name}.sharing <> 'system'",
778 " AND #{Version.table_name}.sharing <> 'system'",
778 conditions),
779 conditions),
779 :include => [:project, :fixed_version]
780 :include => [:project, :fixed_version]
780 ).each do |issue|
781 ).each do |issue|
781 next if issue.project.nil? || issue.fixed_version.nil?
782 next if issue.project.nil? || issue.fixed_version.nil?
782 unless issue.project.shared_versions.include?(issue.fixed_version)
783 unless issue.project.shared_versions.include?(issue.fixed_version)
783 issue.init_journal(User.current)
784 issue.init_journal(User.current)
784 issue.fixed_version = nil
785 issue.fixed_version = nil
785 issue.save
786 issue.save
786 end
787 end
787 end
788 end
788 end
789 end
789
790
790 # Callback on attachment deletion
791 # Callback on attachment deletion
791 def attachment_removed(obj)
792 def attachment_removed(obj)
792 journal = init_journal(User.current)
793 journal = init_journal(User.current)
793 journal.details << JournalDetail.new(:property => 'attachment',
794 journal.details << JournalDetail.new(:property => 'attachment',
794 :prop_key => obj.id,
795 :prop_key => obj.id,
795 :old_value => obj.filename)
796 :old_value => obj.filename)
796 journal.save
797 journal.save
797 end
798 end
798
799
799 # Default assignment based on category
800 # Default assignment based on category
800 def default_assign
801 def default_assign
801 if assigned_to.nil? && category && category.assigned_to
802 if assigned_to.nil? && category && category.assigned_to
802 self.assigned_to = category.assigned_to
803 self.assigned_to = category.assigned_to
803 end
804 end
804 end
805 end
805
806
806 # Updates start/due dates of following issues
807 # Updates start/due dates of following issues
807 def reschedule_following_issues
808 def reschedule_following_issues
808 if start_date_changed? || due_date_changed?
809 if start_date_changed? || due_date_changed?
809 relations_from.each do |relation|
810 relations_from.each do |relation|
810 relation.set_issue_to_dates
811 relation.set_issue_to_dates
811 end
812 end
812 end
813 end
813 end
814 end
814
815
815 # Closes duplicates if the issue is being closed
816 # Closes duplicates if the issue is being closed
816 def close_duplicates
817 def close_duplicates
817 if closing?
818 if closing?
818 duplicates.each do |duplicate|
819 duplicates.each do |duplicate|
819 # Reload is need in case the duplicate was updated by a previous duplicate
820 # Reload is need in case the duplicate was updated by a previous duplicate
820 duplicate.reload
821 duplicate.reload
821 # Don't re-close it if it's already closed
822 # Don't re-close it if it's already closed
822 next if duplicate.closed?
823 next if duplicate.closed?
823 # Same user and notes
824 # Same user and notes
824 if @current_journal
825 if @current_journal
825 duplicate.init_journal(@current_journal.user, @current_journal.notes)
826 duplicate.init_journal(@current_journal.user, @current_journal.notes)
826 end
827 end
827 duplicate.update_attribute :status, self.status
828 duplicate.update_attribute :status, self.status
828 end
829 end
829 end
830 end
830 end
831 end
831
832
832 # Saves the changes in a Journal
833 # Saves the changes in a Journal
833 # Called after_save
834 # Called after_save
834 def create_journal
835 def create_journal
835 if @current_journal
836 if @current_journal
836 # attributes changes
837 # attributes changes
837 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
838 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
838 @current_journal.details << JournalDetail.new(:property => 'attr',
839 @current_journal.details << JournalDetail.new(:property => 'attr',
839 :prop_key => c,
840 :prop_key => c,
840 :old_value => @issue_before_change.send(c),
841 :old_value => @issue_before_change.send(c),
841 :value => send(c)) unless send(c)==@issue_before_change.send(c)
842 :value => send(c)) unless send(c)==@issue_before_change.send(c)
842 }
843 }
843 # custom fields changes
844 # custom fields changes
844 custom_values.each {|c|
845 custom_values.each {|c|
845 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
846 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
846 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
847 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
847 @current_journal.details << JournalDetail.new(:property => 'cf',
848 @current_journal.details << JournalDetail.new(:property => 'cf',
848 :prop_key => c.custom_field_id,
849 :prop_key => c.custom_field_id,
849 :old_value => @custom_values_before_change[c.custom_field_id],
850 :old_value => @custom_values_before_change[c.custom_field_id],
850 :value => c.value)
851 :value => c.value)
851 }
852 }
852 @current_journal.save
853 @current_journal.save
853 # reset current journal
854 # reset current journal
854 init_journal @current_journal.user, @current_journal.notes
855 init_journal @current_journal.user, @current_journal.notes
855 end
856 end
856 end
857 end
857
858
858 # Query generator for selecting groups of issue counts for a project
859 # Query generator for selecting groups of issue counts for a project
859 # based on specific criteria
860 # based on specific criteria
860 #
861 #
861 # Options
862 # Options
862 # * project - Project to search in.
863 # * project - Project to search in.
863 # * field - String. Issue field to key off of in the grouping.
864 # * field - String. Issue field to key off of in the grouping.
864 # * joins - String. The table name to join against.
865 # * joins - String. The table name to join against.
865 def self.count_and_group_by(options)
866 def self.count_and_group_by(options)
866 project = options.delete(:project)
867 project = options.delete(:project)
867 select_field = options.delete(:field)
868 select_field = options.delete(:field)
868 joins = options.delete(:joins)
869 joins = options.delete(:joins)
869
870
870 where = "i.#{select_field}=j.id"
871 where = "i.#{select_field}=j.id"
871
872
872 ActiveRecord::Base.connection.select_all("select s.id as status_id,
873 ActiveRecord::Base.connection.select_all("select s.id as status_id,
873 s.is_closed as closed,
874 s.is_closed as closed,
874 j.id as #{select_field},
875 j.id as #{select_field},
875 count(i.id) as total
876 count(i.id) as total
876 from
877 from
877 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
878 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} j
878 where
879 where
879 i.status_id=s.id
880 i.status_id=s.id
880 and #{where}
881 and #{where}
881 and i.project_id=#{project.id}
882 and i.project_id=#{project.id}
882 group by s.id, s.is_closed, j.id")
883 group by s.id, s.is_closed, j.id")
883 end
884 end
884
885
885
886
886 end
887 end
@@ -1,246 +1,246
1 ---
1 ---
2 issues_001:
2 issues_001:
3 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
3 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
4 project_id: 1
4 project_id: 1
5 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
5 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
6 priority_id: 4
6 priority_id: 4
7 subject: Can't print recipes
7 subject: Can't print recipes
8 id: 1
8 id: 1
9 fixed_version_id:
9 fixed_version_id:
10 category_id: 1
10 category_id: 1
11 description: Unable to print recipes
11 description: Unable to print recipes
12 tracker_id: 1
12 tracker_id: 1
13 assigned_to_id:
13 assigned_to_id:
14 author_id: 2
14 author_id: 2
15 status_id: 1
15 status_id: 1
16 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
16 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
17 due_date: <%= 10.day.from_now.to_date.to_s(:db) %>
17 due_date: <%= 10.day.from_now.to_date.to_s(:db) %>
18 root_id: 1
18 root_id: 1
19 lft: 1
19 lft: 1
20 rgt: 2
20 rgt: 2
21 issues_002:
21 issues_002:
22 created_on: 2006-07-19 21:04:21 +02:00
22 created_on: 2006-07-19 21:04:21 +02:00
23 project_id: 1
23 project_id: 1
24 updated_on: 2006-07-19 21:09:50 +02:00
24 updated_on: 2006-07-19 21:09:50 +02:00
25 priority_id: 5
25 priority_id: 5
26 subject: Add ingredients categories
26 subject: Add ingredients categories
27 id: 2
27 id: 2
28 fixed_version_id: 2
28 fixed_version_id: 2
29 category_id:
29 category_id:
30 description: Ingredients of the recipe should be classified by categories
30 description: Ingredients of the recipe should be classified by categories
31 tracker_id: 2
31 tracker_id: 2
32 assigned_to_id: 3
32 assigned_to_id: 3
33 author_id: 2
33 author_id: 2
34 status_id: 2
34 status_id: 2
35 start_date: <%= 2.day.ago.to_date.to_s(:db) %>
35 start_date: <%= 2.day.ago.to_date.to_s(:db) %>
36 due_date:
36 due_date:
37 root_id: 2
37 root_id: 2
38 lft: 1
38 lft: 1
39 rgt: 2
39 rgt: 2
40 lock_version: 3
40 lock_version: 3
41 done_ratio: 30
41 done_ratio: 30
42 issues_003:
42 issues_003:
43 created_on: 2006-07-19 21:07:27 +02:00
43 created_on: 2006-07-19 21:07:27 +02:00
44 project_id: 1
44 project_id: 1
45 updated_on: 2006-07-19 21:07:27 +02:00
45 updated_on: 2006-07-19 21:07:27 +02:00
46 priority_id: 4
46 priority_id: 4
47 subject: Error 281 when updating a recipe
47 subject: Error 281 when updating a recipe
48 id: 3
48 id: 3
49 fixed_version_id:
49 fixed_version_id:
50 category_id:
50 category_id:
51 description: Error 281 is encountered when saving a recipe
51 description: Error 281 is encountered when saving a recipe
52 tracker_id: 1
52 tracker_id: 1
53 assigned_to_id: 3
53 assigned_to_id: 3
54 author_id: 2
54 author_id: 2
55 status_id: 1
55 status_id: 1
56 start_date: <%= 1.day.from_now.to_date.to_s(:db) %>
56 start_date: <%= 1.day.from_now.to_date.to_s(:db) %>
57 due_date: <%= 40.day.ago.to_date.to_s(:db) %>
57 due_date: <%= 40.day.from_now.to_date.to_s(:db) %>
58 root_id: 3
58 root_id: 3
59 lft: 1
59 lft: 1
60 rgt: 2
60 rgt: 2
61 issues_004:
61 issues_004:
62 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
62 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
63 project_id: 2
63 project_id: 2
64 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
64 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
65 priority_id: 4
65 priority_id: 4
66 subject: Issue on project 2
66 subject: Issue on project 2
67 id: 4
67 id: 4
68 fixed_version_id:
68 fixed_version_id:
69 category_id:
69 category_id:
70 description: Issue on project 2
70 description: Issue on project 2
71 tracker_id: 1
71 tracker_id: 1
72 assigned_to_id: 2
72 assigned_to_id: 2
73 author_id: 2
73 author_id: 2
74 status_id: 1
74 status_id: 1
75 root_id: 4
75 root_id: 4
76 lft: 1
76 lft: 1
77 rgt: 2
77 rgt: 2
78 issues_005:
78 issues_005:
79 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
79 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
80 project_id: 3
80 project_id: 3
81 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
81 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
82 priority_id: 4
82 priority_id: 4
83 subject: Subproject issue
83 subject: Subproject issue
84 id: 5
84 id: 5
85 fixed_version_id:
85 fixed_version_id:
86 category_id:
86 category_id:
87 description: This is an issue on a cookbook subproject
87 description: This is an issue on a cookbook subproject
88 tracker_id: 1
88 tracker_id: 1
89 assigned_to_id:
89 assigned_to_id:
90 author_id: 2
90 author_id: 2
91 status_id: 1
91 status_id: 1
92 root_id: 5
92 root_id: 5
93 lft: 1
93 lft: 1
94 rgt: 2
94 rgt: 2
95 issues_006:
95 issues_006:
96 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
96 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
97 project_id: 5
97 project_id: 5
98 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
98 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
99 priority_id: 4
99 priority_id: 4
100 subject: Issue of a private subproject
100 subject: Issue of a private subproject
101 id: 6
101 id: 6
102 fixed_version_id:
102 fixed_version_id:
103 category_id:
103 category_id:
104 description: This is an issue of a private subproject of cookbook
104 description: This is an issue of a private subproject of cookbook
105 tracker_id: 1
105 tracker_id: 1
106 assigned_to_id:
106 assigned_to_id:
107 author_id: 2
107 author_id: 2
108 status_id: 1
108 status_id: 1
109 start_date: <%= Date.today.to_s(:db) %>
109 start_date: <%= Date.today.to_s(:db) %>
110 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
110 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
111 root_id: 6
111 root_id: 6
112 lft: 1
112 lft: 1
113 rgt: 2
113 rgt: 2
114 issues_007:
114 issues_007:
115 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
115 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
116 project_id: 1
116 project_id: 1
117 updated_on: <%= 10.days.ago.to_date.to_s(:db) %>
117 updated_on: <%= 10.days.ago.to_date.to_s(:db) %>
118 priority_id: 5
118 priority_id: 5
119 subject: Issue due today
119 subject: Issue due today
120 id: 7
120 id: 7
121 fixed_version_id:
121 fixed_version_id:
122 category_id:
122 category_id:
123 description: This is an issue that is due today
123 description: This is an issue that is due today
124 tracker_id: 1
124 tracker_id: 1
125 assigned_to_id:
125 assigned_to_id:
126 author_id: 2
126 author_id: 2
127 status_id: 1
127 status_id: 1
128 start_date: <%= 10.days.ago.to_s(:db) %>
128 start_date: <%= 10.days.ago.to_s(:db) %>
129 due_date: <%= Date.today.to_s(:db) %>
129 due_date: <%= Date.today.to_s(:db) %>
130 lock_version: 0
130 lock_version: 0
131 root_id: 7
131 root_id: 7
132 lft: 1
132 lft: 1
133 rgt: 2
133 rgt: 2
134 issues_008:
134 issues_008:
135 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
135 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
136 project_id: 1
136 project_id: 1
137 updated_on: <%= 10.days.ago.to_date.to_s(:db) %>
137 updated_on: <%= 10.days.ago.to_date.to_s(:db) %>
138 priority_id: 5
138 priority_id: 5
139 subject: Closed issue
139 subject: Closed issue
140 id: 8
140 id: 8
141 fixed_version_id:
141 fixed_version_id:
142 category_id:
142 category_id:
143 description: This is a closed issue.
143 description: This is a closed issue.
144 tracker_id: 1
144 tracker_id: 1
145 assigned_to_id:
145 assigned_to_id:
146 author_id: 2
146 author_id: 2
147 status_id: 5
147 status_id: 5
148 start_date:
148 start_date:
149 due_date:
149 due_date:
150 lock_version: 0
150 lock_version: 0
151 root_id: 8
151 root_id: 8
152 lft: 1
152 lft: 1
153 rgt: 2
153 rgt: 2
154 issues_009:
154 issues_009:
155 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
155 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
156 project_id: 5
156 project_id: 5
157 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
157 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
158 priority_id: 5
158 priority_id: 5
159 subject: Blocked Issue
159 subject: Blocked Issue
160 id: 9
160 id: 9
161 fixed_version_id:
161 fixed_version_id:
162 category_id:
162 category_id:
163 description: This is an issue that is blocked by issue #10
163 description: This is an issue that is blocked by issue #10
164 tracker_id: 1
164 tracker_id: 1
165 assigned_to_id:
165 assigned_to_id:
166 author_id: 2
166 author_id: 2
167 status_id: 1
167 status_id: 1
168 start_date: <%= Date.today.to_s(:db) %>
168 start_date: <%= Date.today.to_s(:db) %>
169 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
169 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
170 root_id: 9
170 root_id: 9
171 lft: 1
171 lft: 1
172 rgt: 2
172 rgt: 2
173 issues_010:
173 issues_010:
174 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
174 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
175 project_id: 5
175 project_id: 5
176 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
176 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
177 priority_id: 5
177 priority_id: 5
178 subject: Issue Doing the Blocking
178 subject: Issue Doing the Blocking
179 id: 10
179 id: 10
180 fixed_version_id:
180 fixed_version_id:
181 category_id:
181 category_id:
182 description: This is an issue that blocks issue #9
182 description: This is an issue that blocks issue #9
183 tracker_id: 1
183 tracker_id: 1
184 assigned_to_id:
184 assigned_to_id:
185 author_id: 2
185 author_id: 2
186 status_id: 1
186 status_id: 1
187 start_date: <%= Date.today.to_s(:db) %>
187 start_date: <%= Date.today.to_s(:db) %>
188 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
188 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
189 root_id: 10
189 root_id: 10
190 lft: 1
190 lft: 1
191 rgt: 2
191 rgt: 2
192 issues_011:
192 issues_011:
193 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
193 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
194 project_id: 1
194 project_id: 1
195 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
195 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
196 priority_id: 5
196 priority_id: 5
197 subject: Closed issue on a closed version
197 subject: Closed issue on a closed version
198 id: 11
198 id: 11
199 fixed_version_id: 1
199 fixed_version_id: 1
200 category_id: 1
200 category_id: 1
201 description:
201 description:
202 tracker_id: 1
202 tracker_id: 1
203 assigned_to_id:
203 assigned_to_id:
204 author_id: 2
204 author_id: 2
205 status_id: 5
205 status_id: 5
206 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
206 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
207 due_date:
207 due_date:
208 root_id: 11
208 root_id: 11
209 lft: 1
209 lft: 1
210 rgt: 2
210 rgt: 2
211 issues_012:
211 issues_012:
212 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
212 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
213 project_id: 1
213 project_id: 1
214 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
214 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
215 priority_id: 5
215 priority_id: 5
216 subject: Closed issue on a locked version
216 subject: Closed issue on a locked version
217 id: 12
217 id: 12
218 fixed_version_id: 2
218 fixed_version_id: 2
219 category_id: 1
219 category_id: 1
220 description:
220 description:
221 tracker_id: 1
221 tracker_id: 1
222 assigned_to_id:
222 assigned_to_id:
223 author_id: 3
223 author_id: 3
224 status_id: 5
224 status_id: 5
225 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
225 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
226 due_date:
226 due_date:
227 root_id: 12
227 root_id: 12
228 lft: 1
228 lft: 1
229 rgt: 2
229 rgt: 2
230 issues_013:
230 issues_013:
231 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
231 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
232 project_id: 3
232 project_id: 3
233 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
233 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
234 priority_id: 4
234 priority_id: 4
235 subject: Subproject issue two
235 subject: Subproject issue two
236 id: 13
236 id: 13
237 fixed_version_id:
237 fixed_version_id:
238 category_id:
238 category_id:
239 description: This is a second issue on a cookbook subproject
239 description: This is a second issue on a cookbook subproject
240 tracker_id: 1
240 tracker_id: 1
241 assigned_to_id:
241 assigned_to_id:
242 author_id: 2
242 author_id: 2
243 status_id: 1
243 status_id: 1
244 root_id: 13
244 root_id: 13
245 lft: 1
245 lft: 1
246 rgt: 2
246 rgt: 2
@@ -1,420 +1,437
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2010 Jean-Philippe Lang
2 # Copyright (C) 2006-2010 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.dirname(__FILE__)}/../../test_helper"
18 require "#{File.dirname(__FILE__)}/../../test_helper"
19
19
20 class ApiTest::IssuesTest < ActionController::IntegrationTest
20 class ApiTest::IssuesTest < ActionController::IntegrationTest
21 fixtures :projects,
21 fixtures :projects,
22 :users,
22 :users,
23 :roles,
23 :roles,
24 :members,
24 :members,
25 :member_roles,
25 :member_roles,
26 :issues,
26 :issues,
27 :issue_statuses,
27 :issue_statuses,
28 :versions,
28 :versions,
29 :trackers,
29 :trackers,
30 :projects_trackers,
30 :projects_trackers,
31 :issue_categories,
31 :issue_categories,
32 :enabled_modules,
32 :enabled_modules,
33 :enumerations,
33 :enumerations,
34 :attachments,
34 :attachments,
35 :workflows,
35 :workflows,
36 :custom_fields,
36 :custom_fields,
37 :custom_values,
37 :custom_values,
38 :custom_fields_projects,
38 :custom_fields_projects,
39 :custom_fields_trackers,
39 :custom_fields_trackers,
40 :time_entries,
40 :time_entries,
41 :journals,
41 :journals,
42 :journal_details,
42 :journal_details,
43 :queries
43 :queries
44
44
45 def setup
45 def setup
46 Setting.rest_api_enabled = '1'
46 Setting.rest_api_enabled = '1'
47 end
47 end
48
48
49 # Use a private project to make sure auth is really working and not just
49 # Use a private project to make sure auth is really working and not just
50 # only showing public issues.
50 # only showing public issues.
51 context "/index.xml" do
51 context "/index.xml" do
52 should_allow_api_authentication(:get, "/projects/private-child/issues.xml")
52 should_allow_api_authentication(:get, "/projects/private-child/issues.xml")
53 end
53 end
54
54
55 context "/index.json" do
55 context "/index.json" do
56 should_allow_api_authentication(:get, "/projects/private-child/issues.json")
56 should_allow_api_authentication(:get, "/projects/private-child/issues.json")
57 end
57 end
58
58
59 context "/index.xml with filter" do
59 context "/index.xml with filter" do
60 should_allow_api_authentication(:get, "/projects/private-child/issues.xml?status_id=5")
60 should_allow_api_authentication(:get, "/projects/private-child/issues.xml?status_id=5")
61
61
62 should "show only issues with the status_id" do
62 should "show only issues with the status_id" do
63 get '/issues.xml?status_id=5'
63 get '/issues.xml?status_id=5'
64 assert_tag :tag => 'issues',
64 assert_tag :tag => 'issues',
65 :children => { :count => Issue.visible.count(:conditions => {:status_id => 5}),
65 :children => { :count => Issue.visible.count(:conditions => {:status_id => 5}),
66 :only => { :tag => 'issue' } }
66 :only => { :tag => 'issue' } }
67 end
67 end
68 end
68 end
69
69
70 context "/index.json with filter" do
70 context "/index.json with filter" do
71 should_allow_api_authentication(:get, "/projects/private-child/issues.json?status_id=5")
71 should_allow_api_authentication(:get, "/projects/private-child/issues.json?status_id=5")
72
72
73 should "show only issues with the status_id" do
73 should "show only issues with the status_id" do
74 get '/issues.json?status_id=5'
74 get '/issues.json?status_id=5'
75
75
76 json = ActiveSupport::JSON.decode(response.body)
76 json = ActiveSupport::JSON.decode(response.body)
77 status_ids_used = json['issues'].collect {|j| j['status']['id'] }
77 status_ids_used = json['issues'].collect {|j| j['status']['id'] }
78 assert_equal 3, status_ids_used.length
78 assert_equal 3, status_ids_used.length
79 assert status_ids_used.all? {|id| id == 5 }
79 assert status_ids_used.all? {|id| id == 5 }
80 end
80 end
81
81
82 end
82 end
83
83
84 # Issue 6 is on a private project
84 # Issue 6 is on a private project
85 context "/issues/6.xml" do
85 context "/issues/6.xml" do
86 should_allow_api_authentication(:get, "/issues/6.xml")
86 should_allow_api_authentication(:get, "/issues/6.xml")
87 end
87 end
88
88
89 context "/issues/6.json" do
89 context "/issues/6.json" do
90 should_allow_api_authentication(:get, "/issues/6.json")
90 should_allow_api_authentication(:get, "/issues/6.json")
91 end
91 end
92
92
93 context "GET /issues/:id" do
93 context "GET /issues/:id" do
94 context "with custom fields" do
94 context "with custom fields" do
95 context ".xml" do
95 context ".xml" do
96 should "display custom fields" do
96 should "display custom fields" do
97 get '/issues/3.xml'
97 get '/issues/3.xml'
98
98
99 assert_tag :tag => 'issue',
99 assert_tag :tag => 'issue',
100 :child => {
100 :child => {
101 :tag => 'custom_fields',
101 :tag => 'custom_fields',
102 :attributes => { :type => 'array' },
102 :attributes => { :type => 'array' },
103 :child => {
103 :child => {
104 :tag => 'custom_field',
104 :tag => 'custom_field',
105 :attributes => { :id => '1'},
105 :attributes => { :id => '1'},
106 :child => {
106 :child => {
107 :tag => 'value',
107 :tag => 'value',
108 :content => 'MySQL'
108 :content => 'MySQL'
109 }
109 }
110 }
110 }
111 }
111 }
112
112
113 assert_nothing_raised do
113 assert_nothing_raised do
114 Hash.from_xml(response.body).to_xml
114 Hash.from_xml(response.body).to_xml
115 end
115 end
116 end
116 end
117 end
117 end
118 end
118 end
119
119
120 context "with subtasks" do
120 context "with subtasks" do
121 setup do
121 setup do
122 @c1 = Issue.generate!(:status_id => 1, :subject => "child c1", :tracker_id => 1, :project_id => 1, :parent_issue_id => 1)
122 @c1 = Issue.generate!(:status_id => 1, :subject => "child c1", :tracker_id => 1, :project_id => 1, :parent_issue_id => 1)
123 @c2 = Issue.generate!(:status_id => 1, :subject => "child c2", :tracker_id => 1, :project_id => 1, :parent_issue_id => 1)
123 @c2 = Issue.generate!(:status_id => 1, :subject => "child c2", :tracker_id => 1, :project_id => 1, :parent_issue_id => 1)
124 @c3 = Issue.generate!(:status_id => 1, :subject => "child c3", :tracker_id => 1, :project_id => 1, :parent_issue_id => @c1.id)
124 @c3 = Issue.generate!(:status_id => 1, :subject => "child c3", :tracker_id => 1, :project_id => 1, :parent_issue_id => @c1.id)
125 end
125 end
126
126
127 context ".xml" do
127 context ".xml" do
128 should "display children" do
128 should "display children" do
129 get '/issues/1.xml'
129 get '/issues/1.xml'
130
130
131 assert_tag :tag => 'issue',
131 assert_tag :tag => 'issue',
132 :child => {
132 :child => {
133 :tag => 'children',
133 :tag => 'children',
134 :children => {:count => 2},
134 :children => {:count => 2},
135 :child => {
135 :child => {
136 :tag => 'issue',
136 :tag => 'issue',
137 :attributes => {:id => @c1.id.to_s},
137 :attributes => {:id => @c1.id.to_s},
138 :child => {
138 :child => {
139 :tag => 'subject',
139 :tag => 'subject',
140 :content => 'child c1',
140 :content => 'child c1',
141 :sibling => {
141 :sibling => {
142 :tag => 'children',
142 :tag => 'children',
143 :children => {:count => 1},
143 :children => {:count => 1},
144 :child => {
144 :child => {
145 :tag => 'issue',
145 :tag => 'issue',
146 :attributes => {:id => @c3.id.to_s}
146 :attributes => {:id => @c3.id.to_s}
147 }
147 }
148 }
148 }
149 }
149 }
150 }
150 }
151 }
151 }
152 end
152 end
153
153
154 context ".json" do
154 context ".json" do
155 should "display children" do
155 should "display children" do
156 get '/issues/1.json'
156 get '/issues/1.json'
157
157
158 json = ActiveSupport::JSON.decode(response.body)
158 json = ActiveSupport::JSON.decode(response.body)
159 assert_equal([
159 assert_equal([
160 {
160 {
161 'id' => @c1.id, 'subject' => 'child c1', 'tracker' => {'id' => 1, 'name' => 'Bug'},
161 'id' => @c1.id, 'subject' => 'child c1', 'tracker' => {'id' => 1, 'name' => 'Bug'},
162 'children' => [{ 'id' => @c3.id, 'subject' => 'child c3', 'tracker' => {'id' => 1, 'name' => 'Bug'} }]
162 'children' => [{ 'id' => @c3.id, 'subject' => 'child c3', 'tracker' => {'id' => 1, 'name' => 'Bug'} }]
163 },
163 },
164 { 'id' => @c2.id, 'subject' => 'child c2', 'tracker' => {'id' => 1, 'name' => 'Bug'} }
164 { 'id' => @c2.id, 'subject' => 'child c2', 'tracker' => {'id' => 1, 'name' => 'Bug'} }
165 ],
165 ],
166 json['issue']['children'])
166 json['issue']['children'])
167 end
167 end
168 end
168 end
169 end
169 end
170 end
170 end
171 end
171 end
172
172
173 context "POST /issues.xml" do
173 context "POST /issues.xml" do
174 should_allow_api_authentication(:post,
174 should_allow_api_authentication(:post,
175 '/issues.xml',
175 '/issues.xml',
176 {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}},
176 {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}},
177 {:success_code => :created})
177 {:success_code => :created})
178
178
179 should "create an issue with the attributes" do
179 should "create an issue with the attributes" do
180 assert_difference('Issue.count') do
180 assert_difference('Issue.count') do
181 post '/issues.xml', {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}}, :authorization => credentials('jsmith')
181 post '/issues.xml', {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}}, :authorization => credentials('jsmith')
182 end
182 end
183
183
184 issue = Issue.first(:order => 'id DESC')
184 issue = Issue.first(:order => 'id DESC')
185 assert_equal 1, issue.project_id
185 assert_equal 1, issue.project_id
186 assert_equal 2, issue.tracker_id
186 assert_equal 2, issue.tracker_id
187 assert_equal 3, issue.status_id
187 assert_equal 3, issue.status_id
188 assert_equal 'API test', issue.subject
188 assert_equal 'API test', issue.subject
189
189
190 assert_response :created
190 assert_response :created
191 assert_equal 'application/xml', @response.content_type
191 assert_equal 'application/xml', @response.content_type
192 assert_tag 'issue', :child => {:tag => 'id', :content => issue.id.to_s}
192 assert_tag 'issue', :child => {:tag => 'id', :content => issue.id.to_s}
193 end
193 end
194 end
194 end
195
195
196 context "POST /issues.xml with failure" do
196 context "POST /issues.xml with failure" do
197 should_allow_api_authentication(:post,
197 should_allow_api_authentication(:post,
198 '/issues.xml',
198 '/issues.xml',
199 {:issue => {:project_id => 1}},
199 {:issue => {:project_id => 1}},
200 {:success_code => :unprocessable_entity})
200 {:success_code => :unprocessable_entity})
201
201
202 should "have an errors tag" do
202 should "have an errors tag" do
203 assert_no_difference('Issue.count') do
203 assert_no_difference('Issue.count') do
204 post '/issues.xml', {:issue => {:project_id => 1}}, :authorization => credentials('jsmith')
204 post '/issues.xml', {:issue => {:project_id => 1}}, :authorization => credentials('jsmith')
205 end
205 end
206
206
207 assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
207 assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
208 end
208 end
209 end
209 end
210
210
211 context "POST /issues.json" do
211 context "POST /issues.json" do
212 should_allow_api_authentication(:post,
212 should_allow_api_authentication(:post,
213 '/issues.json',
213 '/issues.json',
214 {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}},
214 {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}},
215 {:success_code => :created})
215 {:success_code => :created})
216
216
217 should "create an issue with the attributes" do
217 should "create an issue with the attributes" do
218 assert_difference('Issue.count') do
218 assert_difference('Issue.count') do
219 post '/issues.json', {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}}, :authorization => credentials('jsmith')
219 post '/issues.json', {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}}, :authorization => credentials('jsmith')
220 end
220 end
221
221
222 issue = Issue.first(:order => 'id DESC')
222 issue = Issue.first(:order => 'id DESC')
223 assert_equal 1, issue.project_id
223 assert_equal 1, issue.project_id
224 assert_equal 2, issue.tracker_id
224 assert_equal 2, issue.tracker_id
225 assert_equal 3, issue.status_id
225 assert_equal 3, issue.status_id
226 assert_equal 'API test', issue.subject
226 assert_equal 'API test', issue.subject
227 end
227 end
228
228
229 end
229 end
230
230
231 context "POST /issues.json with failure" do
231 context "POST /issues.json with failure" do
232 should_allow_api_authentication(:post,
232 should_allow_api_authentication(:post,
233 '/issues.json',
233 '/issues.json',
234 {:issue => {:project_id => 1}},
234 {:issue => {:project_id => 1}},
235 {:success_code => :unprocessable_entity})
235 {:success_code => :unprocessable_entity})
236
236
237 should "have an errors element" do
237 should "have an errors element" do
238 assert_no_difference('Issue.count') do
238 assert_no_difference('Issue.count') do
239 post '/issues.json', {:issue => {:project_id => 1}}, :authorization => credentials('jsmith')
239 post '/issues.json', {:issue => {:project_id => 1}}, :authorization => credentials('jsmith')
240 end
240 end
241
241
242 json = ActiveSupport::JSON.decode(response.body)
242 json = ActiveSupport::JSON.decode(response.body)
243 assert json['errors'].include?(['subject', "can't be blank"])
243 assert json['errors'].include?(['subject', "can't be blank"])
244 end
244 end
245 end
245 end
246
246
247 # Issue 6 is on a private project
247 # Issue 6 is on a private project
248 context "PUT /issues/6.xml" do
248 context "PUT /issues/6.xml" do
249 setup do
249 setup do
250 @parameters = {:issue => {:subject => 'API update', :notes => 'A new note'}}
250 @parameters = {:issue => {:subject => 'API update', :notes => 'A new note'}}
251 @headers = { :authorization => credentials('jsmith') }
251 @headers = { :authorization => credentials('jsmith') }
252 end
252 end
253
253
254 should_allow_api_authentication(:put,
254 should_allow_api_authentication(:put,
255 '/issues/6.xml',
255 '/issues/6.xml',
256 {:issue => {:subject => 'API update', :notes => 'A new note'}},
256 {:issue => {:subject => 'API update', :notes => 'A new note'}},
257 {:success_code => :ok})
257 {:success_code => :ok})
258
258
259 should "not create a new issue" do
259 should "not create a new issue" do
260 assert_no_difference('Issue.count') do
260 assert_no_difference('Issue.count') do
261 put '/issues/6.xml', @parameters, @headers
261 put '/issues/6.xml', @parameters, @headers
262 end
262 end
263 end
263 end
264
264
265 should "create a new journal" do
265 should "create a new journal" do
266 assert_difference('Journal.count') do
266 assert_difference('Journal.count') do
267 put '/issues/6.xml', @parameters, @headers
267 put '/issues/6.xml', @parameters, @headers
268 end
268 end
269 end
269 end
270
270
271 should "add the note to the journal" do
271 should "add the note to the journal" do
272 put '/issues/6.xml', @parameters, @headers
272 put '/issues/6.xml', @parameters, @headers
273
273
274 journal = Journal.last
274 journal = Journal.last
275 assert_equal "A new note", journal.notes
275 assert_equal "A new note", journal.notes
276 end
276 end
277
277
278 should "update the issue" do
278 should "update the issue" do
279 put '/issues/6.xml', @parameters, @headers
279 put '/issues/6.xml', @parameters, @headers
280
280
281 issue = Issue.find(6)
281 issue = Issue.find(6)
282 assert_equal "API update", issue.subject
282 assert_equal "API update", issue.subject
283 end
283 end
284
284
285 end
285 end
286
286
287 context "PUT /issues/3.xml with custom fields" do
288 setup do
289 @parameters = {:issue => {:custom_fields => [{'id' => '1', 'value' => 'PostgreSQL' }, {'id' => '2', 'value' => '150'}]}}
290 @headers = { :authorization => credentials('jsmith') }
291 end
292
293 should "update custom fields" do
294 assert_no_difference('Issue.count') do
295 put '/issues/3.xml', @parameters, @headers
296 end
297
298 issue = Issue.find(3)
299 assert_equal '150', issue.custom_value_for(2).value
300 assert_equal 'PostgreSQL', issue.custom_value_for(1).value
301 end
302 end
303
287 context "PUT /issues/6.xml with failed update" do
304 context "PUT /issues/6.xml with failed update" do
288 setup do
305 setup do
289 @parameters = {:issue => {:subject => ''}}
306 @parameters = {:issue => {:subject => ''}}
290 @headers = { :authorization => credentials('jsmith') }
307 @headers = { :authorization => credentials('jsmith') }
291 end
308 end
292
309
293 should_allow_api_authentication(:put,
310 should_allow_api_authentication(:put,
294 '/issues/6.xml',
311 '/issues/6.xml',
295 {:issue => {:subject => ''}}, # Missing subject should fail
312 {:issue => {:subject => ''}}, # Missing subject should fail
296 {:success_code => :unprocessable_entity})
313 {:success_code => :unprocessable_entity})
297
314
298 should "not create a new issue" do
315 should "not create a new issue" do
299 assert_no_difference('Issue.count') do
316 assert_no_difference('Issue.count') do
300 put '/issues/6.xml', @parameters, @headers
317 put '/issues/6.xml', @parameters, @headers
301 end
318 end
302 end
319 end
303
320
304 should "not create a new journal" do
321 should "not create a new journal" do
305 assert_no_difference('Journal.count') do
322 assert_no_difference('Journal.count') do
306 put '/issues/6.xml', @parameters, @headers
323 put '/issues/6.xml', @parameters, @headers
307 end
324 end
308 end
325 end
309
326
310 should "have an errors tag" do
327 should "have an errors tag" do
311 put '/issues/6.xml', @parameters, @headers
328 put '/issues/6.xml', @parameters, @headers
312
329
313 assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
330 assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
314 end
331 end
315 end
332 end
316
333
317 context "PUT /issues/6.json" do
334 context "PUT /issues/6.json" do
318 setup do
335 setup do
319 @parameters = {:issue => {:subject => 'API update', :notes => 'A new note'}}
336 @parameters = {:issue => {:subject => 'API update', :notes => 'A new note'}}
320 @headers = { :authorization => credentials('jsmith') }
337 @headers = { :authorization => credentials('jsmith') }
321 end
338 end
322
339
323 should_allow_api_authentication(:put,
340 should_allow_api_authentication(:put,
324 '/issues/6.json',
341 '/issues/6.json',
325 {:issue => {:subject => 'API update', :notes => 'A new note'}},
342 {:issue => {:subject => 'API update', :notes => 'A new note'}},
326 {:success_code => :ok})
343 {:success_code => :ok})
327
344
328 should "not create a new issue" do
345 should "not create a new issue" do
329 assert_no_difference('Issue.count') do
346 assert_no_difference('Issue.count') do
330 put '/issues/6.json', @parameters, @headers
347 put '/issues/6.json', @parameters, @headers
331 end
348 end
332 end
349 end
333
350
334 should "create a new journal" do
351 should "create a new journal" do
335 assert_difference('Journal.count') do
352 assert_difference('Journal.count') do
336 put '/issues/6.json', @parameters, @headers
353 put '/issues/6.json', @parameters, @headers
337 end
354 end
338 end
355 end
339
356
340 should "add the note to the journal" do
357 should "add the note to the journal" do
341 put '/issues/6.json', @parameters, @headers
358 put '/issues/6.json', @parameters, @headers
342
359
343 journal = Journal.last
360 journal = Journal.last
344 assert_equal "A new note", journal.notes
361 assert_equal "A new note", journal.notes
345 end
362 end
346
363
347 should "update the issue" do
364 should "update the issue" do
348 put '/issues/6.json', @parameters, @headers
365 put '/issues/6.json', @parameters, @headers
349
366
350 issue = Issue.find(6)
367 issue = Issue.find(6)
351 assert_equal "API update", issue.subject
368 assert_equal "API update", issue.subject
352 end
369 end
353
370
354 end
371 end
355
372
356 context "PUT /issues/6.json with failed update" do
373 context "PUT /issues/6.json with failed update" do
357 setup do
374 setup do
358 @parameters = {:issue => {:subject => ''}}
375 @parameters = {:issue => {:subject => ''}}
359 @headers = { :authorization => credentials('jsmith') }
376 @headers = { :authorization => credentials('jsmith') }
360 end
377 end
361
378
362 should_allow_api_authentication(:put,
379 should_allow_api_authentication(:put,
363 '/issues/6.json',
380 '/issues/6.json',
364 {:issue => {:subject => ''}}, # Missing subject should fail
381 {:issue => {:subject => ''}}, # Missing subject should fail
365 {:success_code => :unprocessable_entity})
382 {:success_code => :unprocessable_entity})
366
383
367 should "not create a new issue" do
384 should "not create a new issue" do
368 assert_no_difference('Issue.count') do
385 assert_no_difference('Issue.count') do
369 put '/issues/6.json', @parameters, @headers
386 put '/issues/6.json', @parameters, @headers
370 end
387 end
371 end
388 end
372
389
373 should "not create a new journal" do
390 should "not create a new journal" do
374 assert_no_difference('Journal.count') do
391 assert_no_difference('Journal.count') do
375 put '/issues/6.json', @parameters, @headers
392 put '/issues/6.json', @parameters, @headers
376 end
393 end
377 end
394 end
378
395
379 should "have an errors attribute" do
396 should "have an errors attribute" do
380 put '/issues/6.json', @parameters, @headers
397 put '/issues/6.json', @parameters, @headers
381
398
382 json = ActiveSupport::JSON.decode(response.body)
399 json = ActiveSupport::JSON.decode(response.body)
383 assert json['errors'].include?(['subject', "can't be blank"])
400 assert json['errors'].include?(['subject', "can't be blank"])
384 end
401 end
385 end
402 end
386
403
387 context "DELETE /issues/1.xml" do
404 context "DELETE /issues/1.xml" do
388 should_allow_api_authentication(:delete,
405 should_allow_api_authentication(:delete,
389 '/issues/6.xml',
406 '/issues/6.xml',
390 {},
407 {},
391 {:success_code => :ok})
408 {:success_code => :ok})
392
409
393 should "delete the issue" do
410 should "delete the issue" do
394 assert_difference('Issue.count',-1) do
411 assert_difference('Issue.count',-1) do
395 delete '/issues/6.xml', {}, :authorization => credentials('jsmith')
412 delete '/issues/6.xml', {}, :authorization => credentials('jsmith')
396 end
413 end
397
414
398 assert_nil Issue.find_by_id(6)
415 assert_nil Issue.find_by_id(6)
399 end
416 end
400 end
417 end
401
418
402 context "DELETE /issues/1.json" do
419 context "DELETE /issues/1.json" do
403 should_allow_api_authentication(:delete,
420 should_allow_api_authentication(:delete,
404 '/issues/6.json',
421 '/issues/6.json',
405 {},
422 {},
406 {:success_code => :ok})
423 {:success_code => :ok})
407
424
408 should "delete the issue" do
425 should "delete the issue" do
409 assert_difference('Issue.count',-1) do
426 assert_difference('Issue.count',-1) do
410 delete '/issues/6.json', {}, :authorization => credentials('jsmith')
427 delete '/issues/6.json', {}, :authorization => credentials('jsmith')
411 end
428 end
412
429
413 assert_nil Issue.find_by_id(6)
430 assert_nil Issue.find_by_id(6)
414 end
431 end
415 end
432 end
416
433
417 def credentials(user, password=nil)
434 def credentials(user, password=nil)
418 ActionController::HttpAuthentication::Basic.encode_credentials(user, password || user)
435 ActionController::HttpAuthentication::Basic.encode_credentials(user, password || user)
419 end
436 end
420 end
437 end
@@ -1,96 +1,111
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
2 # Copyright (C) 2006-2008 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 Redmine
18 module Redmine
19 module Acts
19 module Acts
20 module Customizable
20 module Customizable
21 def self.included(base)
21 def self.included(base)
22 base.extend ClassMethods
22 base.extend ClassMethods
23 end
23 end
24
24
25 module ClassMethods
25 module ClassMethods
26 def acts_as_customizable(options = {})
26 def acts_as_customizable(options = {})
27 return if self.included_modules.include?(Redmine::Acts::Customizable::InstanceMethods)
27 return if self.included_modules.include?(Redmine::Acts::Customizable::InstanceMethods)
28 cattr_accessor :customizable_options
28 cattr_accessor :customizable_options
29 self.customizable_options = options
29 self.customizable_options = options
30 has_many :custom_values, :as => :customized,
30 has_many :custom_values, :as => :customized,
31 :include => :custom_field,
31 :include => :custom_field,
32 :order => "#{CustomField.table_name}.position",
32 :order => "#{CustomField.table_name}.position",
33 :dependent => :delete_all
33 :dependent => :delete_all
34 before_validation_on_create { |customized| customized.custom_field_values }
34 before_validation_on_create { |customized| customized.custom_field_values }
35 # Trigger validation only if custom values were changed
35 # Trigger validation only if custom values were changed
36 validates_associated :custom_values, :on => :update, :if => Proc.new { |customized| customized.custom_field_values_changed? }
36 validates_associated :custom_values, :on => :update, :if => Proc.new { |customized| customized.custom_field_values_changed? }
37 send :include, Redmine::Acts::Customizable::InstanceMethods
37 send :include, Redmine::Acts::Customizable::InstanceMethods
38 # Save custom values when saving the customized object
38 # Save custom values when saving the customized object
39 after_save :save_custom_field_values
39 after_save :save_custom_field_values
40 end
40 end
41 end
41 end
42
42
43 module InstanceMethods
43 module InstanceMethods
44 def self.included(base)
44 def self.included(base)
45 base.extend ClassMethods
45 base.extend ClassMethods
46 end
46 end
47
47
48 def available_custom_fields
48 def available_custom_fields
49 CustomField.find(:all, :conditions => "type = '#{self.class.name}CustomField'",
49 CustomField.find(:all, :conditions => "type = '#{self.class.name}CustomField'",
50 :order => 'position')
50 :order => 'position')
51 end
51 end
52
52
53 # Sets the values of the object's custom fields
54 # values is an array like [{'id' => 1, 'value' => 'foo'}, {'id' => 2, 'value' => 'bar'}]
55 def custom_fields=(values)
56 values_to_hash = values.inject({}) do |hash, v|
57 v = v.stringify_keys
58 if v['id'] && v.has_key?('value')
59 hash[v['id']] = v['value']
60 end
61 hash
62 end
63 self.custom_field_values = values_to_hash
64 end
65
66 # Sets the values of the object's custom fields
67 # values is a hash like {'1' => 'foo', 2 => 'bar'}
53 def custom_field_values=(values)
68 def custom_field_values=(values)
54 @custom_field_values_changed = true
69 @custom_field_values_changed = true
55 values = values.stringify_keys
70 values = values.stringify_keys
56 custom_field_values.each do |custom_value|
71 custom_field_values.each do |custom_value|
57 custom_value.value = values[custom_value.custom_field_id.to_s] if values.has_key?(custom_value.custom_field_id.to_s)
72 custom_value.value = values[custom_value.custom_field_id.to_s] if values.has_key?(custom_value.custom_field_id.to_s)
58 end if values.is_a?(Hash)
73 end if values.is_a?(Hash)
59 end
74 end
60
75
61 def custom_field_values
76 def custom_field_values
62 @custom_field_values ||= available_custom_fields.collect { |x| custom_values.detect { |v| v.custom_field == x } || custom_values.build(:custom_field => x, :value => nil) }
77 @custom_field_values ||= available_custom_fields.collect { |x| custom_values.detect { |v| v.custom_field == x } || custom_values.build(:custom_field => x, :value => nil) }
63 end
78 end
64
79
65 def visible_custom_field_values
80 def visible_custom_field_values
66 custom_field_values.select(&:visible?)
81 custom_field_values.select(&:visible?)
67 end
82 end
68
83
69 def custom_field_values_changed?
84 def custom_field_values_changed?
70 @custom_field_values_changed == true
85 @custom_field_values_changed == true
71 end
86 end
72
87
73 def custom_value_for(c)
88 def custom_value_for(c)
74 field_id = (c.is_a?(CustomField) ? c.id : c.to_i)
89 field_id = (c.is_a?(CustomField) ? c.id : c.to_i)
75 custom_values.detect {|v| v.custom_field_id == field_id }
90 custom_values.detect {|v| v.custom_field_id == field_id }
76 end
91 end
77
92
78 def save_custom_field_values
93 def save_custom_field_values
79 custom_field_values.each(&:save)
94 custom_field_values.each(&:save)
80 @custom_field_values_changed = false
95 @custom_field_values_changed = false
81 @custom_field_values = nil
96 @custom_field_values = nil
82 end
97 end
83
98
84 def reset_custom_values!
99 def reset_custom_values!
85 @custom_field_values = nil
100 @custom_field_values = nil
86 @custom_field_values_changed = true
101 @custom_field_values_changed = true
87 values = custom_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
102 values = custom_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
88 custom_values.each {|cv| cv.destroy unless custom_field_values.include?(cv)}
103 custom_values.each {|cv| cv.destroy unless custom_field_values.include?(cv)}
89 end
104 end
90
105
91 module ClassMethods
106 module ClassMethods
92 end
107 end
93 end
108 end
94 end
109 end
95 end
110 end
96 end
111 end
General Comments 0
You need to be logged in to leave comments. Login now