##// END OF EJS Templates
Fixed that creating an issue without tracker_id attribute ignores custom field values (#19368)....
Jean-Philippe Lang -
r13701:5e1d042c40ac
parent child
Show More
@@ -1,1574 +1,1579
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20 include Redmine::Utils::DateCalculation
20 include Redmine::Utils::DateCalculation
21 include Redmine::I18n
21 include Redmine::I18n
22 before_save :set_parent_id
22 before_save :set_parent_id
23 include Redmine::NestedSet::IssueNestedSet
23 include Redmine::NestedSet::IssueNestedSet
24
24
25 belongs_to :project
25 belongs_to :project
26 belongs_to :tracker
26 belongs_to :tracker
27 belongs_to :status, :class_name => 'IssueStatus'
27 belongs_to :status, :class_name => 'IssueStatus'
28 belongs_to :author, :class_name => 'User'
28 belongs_to :author, :class_name => 'User'
29 belongs_to :assigned_to, :class_name => 'Principal'
29 belongs_to :assigned_to, :class_name => 'Principal'
30 belongs_to :fixed_version, :class_name => 'Version'
30 belongs_to :fixed_version, :class_name => 'Version'
31 belongs_to :priority, :class_name => 'IssuePriority'
31 belongs_to :priority, :class_name => 'IssuePriority'
32 belongs_to :category, :class_name => 'IssueCategory'
32 belongs_to :category, :class_name => 'IssueCategory'
33
33
34 has_many :journals, :as => :journalized, :dependent => :destroy, :inverse_of => :journalized
34 has_many :journals, :as => :journalized, :dependent => :destroy, :inverse_of => :journalized
35 has_many :visible_journals,
35 has_many :visible_journals,
36 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
36 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
37 :class_name => 'Journal',
37 :class_name => 'Journal',
38 :as => :journalized
38 :as => :journalized
39
39
40 has_many :time_entries, :dependent => :destroy
40 has_many :time_entries, :dependent => :destroy
41 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
41 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
42
42
43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45
45
46 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
46 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
47 acts_as_customizable
47 acts_as_customizable
48 acts_as_watchable
48 acts_as_watchable
49 acts_as_searchable :columns => ['subject', "#{table_name}.description"],
49 acts_as_searchable :columns => ['subject', "#{table_name}.description"],
50 :preload => [:project, :status, :tracker],
50 :preload => [:project, :status, :tracker],
51 :scope => lambda {|options| options[:open_issues] ? self.open : self.all}
51 :scope => lambda {|options| options[:open_issues] ? self.open : self.all}
52
52
53 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
53 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
54 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
54 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
55 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
55 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
56
56
57 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
57 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
58 :author_key => :author_id
58 :author_key => :author_id
59
59
60 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
60 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
61
61
62 attr_reader :current_journal
62 attr_reader :current_journal
63 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
63 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
64
64
65 validates_presence_of :subject, :project, :tracker
65 validates_presence_of :subject, :project, :tracker
66 validates_presence_of :priority, :if => Proc.new {|issue| issue.new_record? || issue.priority_id_changed?}
66 validates_presence_of :priority, :if => Proc.new {|issue| issue.new_record? || issue.priority_id_changed?}
67 validates_presence_of :status, :if => Proc.new {|issue| issue.new_record? || issue.status_id_changed?}
67 validates_presence_of :status, :if => Proc.new {|issue| issue.new_record? || issue.status_id_changed?}
68 validates_presence_of :author, :if => Proc.new {|issue| issue.new_record? || issue.author_id_changed?}
68 validates_presence_of :author, :if => Proc.new {|issue| issue.new_record? || issue.author_id_changed?}
69
69
70 validates_length_of :subject, :maximum => 255
70 validates_length_of :subject, :maximum => 255
71 validates_inclusion_of :done_ratio, :in => 0..100
71 validates_inclusion_of :done_ratio, :in => 0..100
72 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
72 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
73 validates :start_date, :date => true
73 validates :start_date, :date => true
74 validates :due_date, :date => true
74 validates :due_date, :date => true
75 validate :validate_issue, :validate_required_fields
75 validate :validate_issue, :validate_required_fields
76 attr_protected :id
76 attr_protected :id
77
77
78 scope :visible, lambda {|*args|
78 scope :visible, lambda {|*args|
79 joins(:project).
79 joins(:project).
80 where(Issue.visible_condition(args.shift || User.current, *args))
80 where(Issue.visible_condition(args.shift || User.current, *args))
81 }
81 }
82
82
83 scope :open, lambda {|*args|
83 scope :open, lambda {|*args|
84 is_closed = args.size > 0 ? !args.first : false
84 is_closed = args.size > 0 ? !args.first : false
85 joins(:status).
85 joins(:status).
86 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
86 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
87 }
87 }
88
88
89 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
89 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
90 scope :on_active_project, lambda {
90 scope :on_active_project, lambda {
91 joins(:project).
91 joins(:project).
92 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
92 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
93 }
93 }
94 scope :fixed_version, lambda {|versions|
94 scope :fixed_version, lambda {|versions|
95 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
95 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
96 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
96 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
97 }
97 }
98
98
99 before_create :default_assign
99 before_create :default_assign
100 before_save :close_duplicates, :update_done_ratio_from_issue_status,
100 before_save :close_duplicates, :update_done_ratio_from_issue_status,
101 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
101 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
102 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
102 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
103 after_save :reschedule_following_issues, :update_nested_set_attributes,
103 after_save :reschedule_following_issues, :update_nested_set_attributes,
104 :update_parent_attributes, :create_journal
104 :update_parent_attributes, :create_journal
105 # Should be after_create but would be called before previous after_save callbacks
105 # Should be after_create but would be called before previous after_save callbacks
106 after_save :after_create_from_copy
106 after_save :after_create_from_copy
107 after_destroy :update_parent_attributes
107 after_destroy :update_parent_attributes
108 after_create :send_notification
108 after_create :send_notification
109 # Keep it at the end of after_save callbacks
109 # Keep it at the end of after_save callbacks
110 after_save :clear_assigned_to_was
110 after_save :clear_assigned_to_was
111
111
112 # Returns a SQL conditions string used to find all issues visible by the specified user
112 # Returns a SQL conditions string used to find all issues visible by the specified user
113 def self.visible_condition(user, options={})
113 def self.visible_condition(user, options={})
114 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
114 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
115 if user.id && user.logged?
115 if user.id && user.logged?
116 case role.issues_visibility
116 case role.issues_visibility
117 when 'all'
117 when 'all'
118 nil
118 nil
119 when 'default'
119 when 'default'
120 user_ids = [user.id] + user.groups.map(&:id).compact
120 user_ids = [user.id] + user.groups.map(&:id).compact
121 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
121 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
122 when 'own'
122 when 'own'
123 user_ids = [user.id] + user.groups.map(&:id).compact
123 user_ids = [user.id] + user.groups.map(&:id).compact
124 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
124 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
125 else
125 else
126 '1=0'
126 '1=0'
127 end
127 end
128 else
128 else
129 "(#{table_name}.is_private = #{connection.quoted_false})"
129 "(#{table_name}.is_private = #{connection.quoted_false})"
130 end
130 end
131 end
131 end
132 end
132 end
133
133
134 # Returns true if usr or current user is allowed to view the issue
134 # Returns true if usr or current user is allowed to view the issue
135 def visible?(usr=nil)
135 def visible?(usr=nil)
136 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
136 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
137 if user.logged?
137 if user.logged?
138 case role.issues_visibility
138 case role.issues_visibility
139 when 'all'
139 when 'all'
140 true
140 true
141 when 'default'
141 when 'default'
142 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
142 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
143 when 'own'
143 when 'own'
144 self.author == user || user.is_or_belongs_to?(assigned_to)
144 self.author == user || user.is_or_belongs_to?(assigned_to)
145 else
145 else
146 false
146 false
147 end
147 end
148 else
148 else
149 !self.is_private?
149 !self.is_private?
150 end
150 end
151 end
151 end
152 end
152 end
153
153
154 # Returns true if user or current user is allowed to edit or add a note to the issue
154 # Returns true if user or current user is allowed to edit or add a note to the issue
155 def editable?(user=User.current)
155 def editable?(user=User.current)
156 attributes_editable?(user) || user.allowed_to?(:add_issue_notes, project)
156 attributes_editable?(user) || user.allowed_to?(:add_issue_notes, project)
157 end
157 end
158
158
159 # Returns true if user or current user is allowed to edit the issue
159 # Returns true if user or current user is allowed to edit the issue
160 def attributes_editable?(user=User.current)
160 def attributes_editable?(user=User.current)
161 user.allowed_to?(:edit_issues, project)
161 user.allowed_to?(:edit_issues, project)
162 end
162 end
163
163
164 def initialize(attributes=nil, *args)
164 def initialize(attributes=nil, *args)
165 super
165 super
166 if new_record?
166 if new_record?
167 # set default values for new records only
167 # set default values for new records only
168 self.priority ||= IssuePriority.default
168 self.priority ||= IssuePriority.default
169 self.watcher_user_ids = []
169 self.watcher_user_ids = []
170 end
170 end
171 end
171 end
172
172
173 def create_or_update
173 def create_or_update
174 super
174 super
175 ensure
175 ensure
176 @status_was = nil
176 @status_was = nil
177 end
177 end
178 private :create_or_update
178 private :create_or_update
179
179
180 # AR#Persistence#destroy would raise and RecordNotFound exception
180 # AR#Persistence#destroy would raise and RecordNotFound exception
181 # if the issue was already deleted or updated (non matching lock_version).
181 # if the issue was already deleted or updated (non matching lock_version).
182 # This is a problem when bulk deleting issues or deleting a project
182 # This is a problem when bulk deleting issues or deleting a project
183 # (because an issue may already be deleted if its parent was deleted
183 # (because an issue may already be deleted if its parent was deleted
184 # first).
184 # first).
185 # The issue is reloaded by the nested_set before being deleted so
185 # The issue is reloaded by the nested_set before being deleted so
186 # the lock_version condition should not be an issue but we handle it.
186 # the lock_version condition should not be an issue but we handle it.
187 def destroy
187 def destroy
188 super
188 super
189 rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotFound
189 rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotFound
190 # Stale or already deleted
190 # Stale or already deleted
191 begin
191 begin
192 reload
192 reload
193 rescue ActiveRecord::RecordNotFound
193 rescue ActiveRecord::RecordNotFound
194 # The issue was actually already deleted
194 # The issue was actually already deleted
195 @destroyed = true
195 @destroyed = true
196 return freeze
196 return freeze
197 end
197 end
198 # The issue was stale, retry to destroy
198 # The issue was stale, retry to destroy
199 super
199 super
200 end
200 end
201
201
202 alias :base_reload :reload
202 alias :base_reload :reload
203 def reload(*args)
203 def reload(*args)
204 @workflow_rule_by_attribute = nil
204 @workflow_rule_by_attribute = nil
205 @assignable_versions = nil
205 @assignable_versions = nil
206 @relations = nil
206 @relations = nil
207 @spent_hours = nil
207 @spent_hours = nil
208 base_reload(*args)
208 base_reload(*args)
209 end
209 end
210
210
211 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
211 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
212 def available_custom_fields
212 def available_custom_fields
213 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
213 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
214 end
214 end
215
215
216 def visible_custom_field_values(user=nil)
216 def visible_custom_field_values(user=nil)
217 user_real = user || User.current
217 user_real = user || User.current
218 custom_field_values.select do |value|
218 custom_field_values.select do |value|
219 value.custom_field.visible_by?(project, user_real)
219 value.custom_field.visible_by?(project, user_real)
220 end
220 end
221 end
221 end
222
222
223 # Copies attributes from another issue, arg can be an id or an Issue
223 # Copies attributes from another issue, arg can be an id or an Issue
224 def copy_from(arg, options={})
224 def copy_from(arg, options={})
225 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
225 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
226 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
226 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
227 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
227 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
228 self.status = issue.status
228 self.status = issue.status
229 self.author = User.current
229 self.author = User.current
230 unless options[:attachments] == false
230 unless options[:attachments] == false
231 self.attachments = issue.attachments.map do |attachement|
231 self.attachments = issue.attachments.map do |attachement|
232 attachement.copy(:container => self)
232 attachement.copy(:container => self)
233 end
233 end
234 end
234 end
235 @copied_from = issue
235 @copied_from = issue
236 @copy_options = options
236 @copy_options = options
237 self
237 self
238 end
238 end
239
239
240 # Returns an unsaved copy of the issue
240 # Returns an unsaved copy of the issue
241 def copy(attributes=nil, copy_options={})
241 def copy(attributes=nil, copy_options={})
242 copy = self.class.new.copy_from(self, copy_options)
242 copy = self.class.new.copy_from(self, copy_options)
243 copy.attributes = attributes if attributes
243 copy.attributes = attributes if attributes
244 copy
244 copy
245 end
245 end
246
246
247 # Returns true if the issue is a copy
247 # Returns true if the issue is a copy
248 def copy?
248 def copy?
249 @copied_from.present?
249 @copied_from.present?
250 end
250 end
251
251
252 def status_id=(status_id)
252 def status_id=(status_id)
253 if status_id.to_s != self.status_id.to_s
253 if status_id.to_s != self.status_id.to_s
254 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
254 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
255 end
255 end
256 self.status_id
256 self.status_id
257 end
257 end
258
258
259 # Sets the status.
259 # Sets the status.
260 def status=(status)
260 def status=(status)
261 if status != self.status
261 if status != self.status
262 @workflow_rule_by_attribute = nil
262 @workflow_rule_by_attribute = nil
263 end
263 end
264 association(:status).writer(status)
264 association(:status).writer(status)
265 end
265 end
266
266
267 def priority_id=(pid)
267 def priority_id=(pid)
268 self.priority = nil
268 self.priority = nil
269 write_attribute(:priority_id, pid)
269 write_attribute(:priority_id, pid)
270 end
270 end
271
271
272 def category_id=(cid)
272 def category_id=(cid)
273 self.category = nil
273 self.category = nil
274 write_attribute(:category_id, cid)
274 write_attribute(:category_id, cid)
275 end
275 end
276
276
277 def fixed_version_id=(vid)
277 def fixed_version_id=(vid)
278 self.fixed_version = nil
278 self.fixed_version = nil
279 write_attribute(:fixed_version_id, vid)
279 write_attribute(:fixed_version_id, vid)
280 end
280 end
281
281
282 def tracker_id=(tracker_id)
282 def tracker_id=(tracker_id)
283 if tracker_id.to_s != self.tracker_id.to_s
283 if tracker_id.to_s != self.tracker_id.to_s
284 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
284 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
285 end
285 end
286 self.tracker_id
286 self.tracker_id
287 end
287 end
288
288
289 # Sets the tracker.
289 # Sets the tracker.
290 # This will set the status to the default status of the new tracker if:
290 # This will set the status to the default status of the new tracker if:
291 # * the status was the default for the previous tracker
291 # * the status was the default for the previous tracker
292 # * or if the status was not part of the new tracker statuses
292 # * or if the status was not part of the new tracker statuses
293 # * or the status was nil
293 # * or the status was nil
294 def tracker=(tracker)
294 def tracker=(tracker)
295 if tracker != self.tracker
295 if tracker != self.tracker
296 if status == default_status
296 if status == default_status
297 self.status = nil
297 self.status = nil
298 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
298 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
299 self.status = nil
299 self.status = nil
300 end
300 end
301 @custom_field_values = nil
301 @custom_field_values = nil
302 @workflow_rule_by_attribute = nil
302 @workflow_rule_by_attribute = nil
303 end
303 end
304 association(:tracker).writer(tracker)
304 association(:tracker).writer(tracker)
305 self.status ||= default_status
305 self.status ||= default_status
306 self.tracker
306 self.tracker
307 end
307 end
308
308
309 def project_id=(project_id)
309 def project_id=(project_id)
310 if project_id.to_s != self.project_id.to_s
310 if project_id.to_s != self.project_id.to_s
311 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
311 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
312 end
312 end
313 self.project_id
313 self.project_id
314 end
314 end
315
315
316 # Sets the project.
316 # Sets the project.
317 # Unless keep_tracker argument is set to true, this will change the tracker
317 # Unless keep_tracker argument is set to true, this will change the tracker
318 # to the first tracker of the new project if the previous tracker is not part
318 # to the first tracker of the new project if the previous tracker is not part
319 # of the new project trackers.
319 # of the new project trackers.
320 # This will clear the fixed_version is it's no longer valid for the new project.
320 # This will clear the fixed_version is it's no longer valid for the new project.
321 # This will clear the parent issue if it's no longer valid for the new project.
321 # This will clear the parent issue if it's no longer valid for the new project.
322 # This will set the category to the category with the same name in the new
322 # This will set the category to the category with the same name in the new
323 # project if it exists, or clear it if it doesn't.
323 # project if it exists, or clear it if it doesn't.
324 def project=(project, keep_tracker=false)
324 def project=(project, keep_tracker=false)
325 project_was = self.project
325 project_was = self.project
326 association(:project).writer(project)
326 association(:project).writer(project)
327 if project_was && project && project_was != project
327 if project_was && project && project_was != project
328 @assignable_versions = nil
328 @assignable_versions = nil
329
329
330 unless keep_tracker || project.trackers.include?(tracker)
330 unless keep_tracker || project.trackers.include?(tracker)
331 self.tracker = project.trackers.first
331 self.tracker = project.trackers.first
332 end
332 end
333 # Reassign to the category with same name if any
333 # Reassign to the category with same name if any
334 if category
334 if category
335 self.category = project.issue_categories.find_by_name(category.name)
335 self.category = project.issue_categories.find_by_name(category.name)
336 end
336 end
337 # Keep the fixed_version if it's still valid in the new_project
337 # Keep the fixed_version if it's still valid in the new_project
338 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
338 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
339 self.fixed_version = nil
339 self.fixed_version = nil
340 end
340 end
341 # Clear the parent task if it's no longer valid
341 # Clear the parent task if it's no longer valid
342 unless valid_parent_project?
342 unless valid_parent_project?
343 self.parent_issue_id = nil
343 self.parent_issue_id = nil
344 end
344 end
345 @custom_field_values = nil
345 @custom_field_values = nil
346 @workflow_rule_by_attribute = nil
346 @workflow_rule_by_attribute = nil
347 end
347 end
348 self.project
348 self.project
349 end
349 end
350
350
351 def description=(arg)
351 def description=(arg)
352 if arg.is_a?(String)
352 if arg.is_a?(String)
353 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
353 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
354 end
354 end
355 write_attribute(:description, arg)
355 write_attribute(:description, arg)
356 end
356 end
357
357
358 # Overrides assign_attributes so that project and tracker get assigned first
358 # Overrides assign_attributes so that project and tracker get assigned first
359 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
359 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
360 return if new_attributes.nil?
360 return if new_attributes.nil?
361 attrs = new_attributes.dup
361 attrs = new_attributes.dup
362 attrs.stringify_keys!
362 attrs.stringify_keys!
363
363
364 %w(project project_id tracker tracker_id).each do |attr|
364 %w(project project_id tracker tracker_id).each do |attr|
365 if attrs.has_key?(attr)
365 if attrs.has_key?(attr)
366 send "#{attr}=", attrs.delete(attr)
366 send "#{attr}=", attrs.delete(attr)
367 end
367 end
368 end
368 end
369 send :assign_attributes_without_project_and_tracker_first, attrs, *args
369 send :assign_attributes_without_project_and_tracker_first, attrs, *args
370 end
370 end
371 # Do not redefine alias chain on reload (see #4838)
371 # Do not redefine alias chain on reload (see #4838)
372 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
372 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
373
373
374 def attributes=(new_attributes)
374 def attributes=(new_attributes)
375 assign_attributes new_attributes
375 assign_attributes new_attributes
376 end
376 end
377
377
378 def estimated_hours=(h)
378 def estimated_hours=(h)
379 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
379 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
380 end
380 end
381
381
382 safe_attributes 'project_id',
382 safe_attributes 'project_id',
383 'tracker_id',
383 'tracker_id',
384 'status_id',
384 'status_id',
385 'category_id',
385 'category_id',
386 'assigned_to_id',
386 'assigned_to_id',
387 'priority_id',
387 'priority_id',
388 'fixed_version_id',
388 'fixed_version_id',
389 'subject',
389 'subject',
390 'description',
390 'description',
391 'start_date',
391 'start_date',
392 'due_date',
392 'due_date',
393 'done_ratio',
393 'done_ratio',
394 'estimated_hours',
394 'estimated_hours',
395 'custom_field_values',
395 'custom_field_values',
396 'custom_fields',
396 'custom_fields',
397 'lock_version',
397 'lock_version',
398 'notes',
398 'notes',
399 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
399 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
400
400
401 safe_attributes 'notes',
401 safe_attributes 'notes',
402 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
402 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
403
403
404 safe_attributes 'private_notes',
404 safe_attributes 'private_notes',
405 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
405 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
406
406
407 safe_attributes 'watcher_user_ids',
407 safe_attributes 'watcher_user_ids',
408 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
408 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
409
409
410 safe_attributes 'is_private',
410 safe_attributes 'is_private',
411 :if => lambda {|issue, user|
411 :if => lambda {|issue, user|
412 user.allowed_to?(:set_issues_private, issue.project) ||
412 user.allowed_to?(:set_issues_private, issue.project) ||
413 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
413 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
414 }
414 }
415
415
416 safe_attributes 'parent_issue_id',
416 safe_attributes 'parent_issue_id',
417 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
417 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
418 user.allowed_to?(:manage_subtasks, issue.project)}
418 user.allowed_to?(:manage_subtasks, issue.project)}
419
419
420 def safe_attribute_names(user=nil)
420 def safe_attribute_names(user=nil)
421 names = super
421 names = super
422 names -= disabled_core_fields
422 names -= disabled_core_fields
423 names -= read_only_attribute_names(user)
423 names -= read_only_attribute_names(user)
424 if new_record?
424 if new_record?
425 # Make sure that project_id can always be set for new issues
425 # Make sure that project_id can always be set for new issues
426 names |= %w(project_id)
426 names |= %w(project_id)
427 end
427 end
428 names
428 names
429 end
429 end
430
430
431 # Safely sets attributes
431 # Safely sets attributes
432 # Should be called from controllers instead of #attributes=
432 # Should be called from controllers instead of #attributes=
433 # attr_accessible is too rough because we still want things like
433 # attr_accessible is too rough because we still want things like
434 # Issue.new(:project => foo) to work
434 # Issue.new(:project => foo) to work
435 def safe_attributes=(attrs, user=User.current)
435 def safe_attributes=(attrs, user=User.current)
436 return unless attrs.is_a?(Hash)
436 return unless attrs.is_a?(Hash)
437
437
438 attrs = attrs.deep_dup
438 attrs = attrs.deep_dup
439
439
440 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
440 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
441 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
441 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
442 if allowed_target_projects(user).where(:id => p.to_i).exists?
442 if allowed_target_projects(user).where(:id => p.to_i).exists?
443 self.project_id = p
443 self.project_id = p
444 end
444 end
445 end
445 end
446
446
447 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
447 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
448 self.tracker_id = t
448 self.tracker_id = t
449 end
449 end
450 if project
451 # Set the default tracker to accept custom field values
452 # even if tracker is not specified
453 self.tracker ||= project.trackers.first
454 end
450
455
451 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
456 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
452 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
457 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
453 self.status_id = s
458 self.status_id = s
454 end
459 end
455 end
460 end
456
461
457 attrs = delete_unsafe_attributes(attrs, user)
462 attrs = delete_unsafe_attributes(attrs, user)
458 return if attrs.empty?
463 return if attrs.empty?
459
464
460 unless leaf?
465 unless leaf?
461 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
466 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
462 end
467 end
463
468
464 if attrs['parent_issue_id'].present?
469 if attrs['parent_issue_id'].present?
465 s = attrs['parent_issue_id'].to_s
470 s = attrs['parent_issue_id'].to_s
466 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
471 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
467 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
472 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
468 end
473 end
469 end
474 end
470
475
471 if attrs['custom_field_values'].present?
476 if attrs['custom_field_values'].present?
472 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
477 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
473 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
478 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
474 end
479 end
475
480
476 if attrs['custom_fields'].present?
481 if attrs['custom_fields'].present?
477 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
482 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
478 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
483 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
479 end
484 end
480
485
481 # mass-assignment security bypass
486 # mass-assignment security bypass
482 assign_attributes attrs, :without_protection => true
487 assign_attributes attrs, :without_protection => true
483 end
488 end
484
489
485 def disabled_core_fields
490 def disabled_core_fields
486 tracker ? tracker.disabled_core_fields : []
491 tracker ? tracker.disabled_core_fields : []
487 end
492 end
488
493
489 # Returns the custom_field_values that can be edited by the given user
494 # Returns the custom_field_values that can be edited by the given user
490 def editable_custom_field_values(user=nil)
495 def editable_custom_field_values(user=nil)
491 visible_custom_field_values(user).reject do |value|
496 visible_custom_field_values(user).reject do |value|
492 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
497 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
493 end
498 end
494 end
499 end
495
500
496 # Returns the custom fields that can be edited by the given user
501 # Returns the custom fields that can be edited by the given user
497 def editable_custom_fields(user=nil)
502 def editable_custom_fields(user=nil)
498 editable_custom_field_values(user).map(&:custom_field).uniq
503 editable_custom_field_values(user).map(&:custom_field).uniq
499 end
504 end
500
505
501 # Returns the names of attributes that are read-only for user or the current user
506 # Returns the names of attributes that are read-only for user or the current user
502 # For users with multiple roles, the read-only fields are the intersection of
507 # For users with multiple roles, the read-only fields are the intersection of
503 # read-only fields of each role
508 # read-only fields of each role
504 # The result is an array of strings where sustom fields are represented with their ids
509 # The result is an array of strings where sustom fields are represented with their ids
505 #
510 #
506 # Examples:
511 # Examples:
507 # issue.read_only_attribute_names # => ['due_date', '2']
512 # issue.read_only_attribute_names # => ['due_date', '2']
508 # issue.read_only_attribute_names(user) # => []
513 # issue.read_only_attribute_names(user) # => []
509 def read_only_attribute_names(user=nil)
514 def read_only_attribute_names(user=nil)
510 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
515 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
511 end
516 end
512
517
513 # Returns the names of required attributes for user or the current user
518 # Returns the names of required attributes for user or the current user
514 # For users with multiple roles, the required fields are the intersection of
519 # For users with multiple roles, the required fields are the intersection of
515 # required fields of each role
520 # required fields of each role
516 # The result is an array of strings where sustom fields are represented with their ids
521 # The result is an array of strings where sustom fields are represented with their ids
517 #
522 #
518 # Examples:
523 # Examples:
519 # issue.required_attribute_names # => ['due_date', '2']
524 # issue.required_attribute_names # => ['due_date', '2']
520 # issue.required_attribute_names(user) # => []
525 # issue.required_attribute_names(user) # => []
521 def required_attribute_names(user=nil)
526 def required_attribute_names(user=nil)
522 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
527 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
523 end
528 end
524
529
525 # Returns true if the attribute is required for user
530 # Returns true if the attribute is required for user
526 def required_attribute?(name, user=nil)
531 def required_attribute?(name, user=nil)
527 required_attribute_names(user).include?(name.to_s)
532 required_attribute_names(user).include?(name.to_s)
528 end
533 end
529
534
530 # Returns a hash of the workflow rule by attribute for the given user
535 # Returns a hash of the workflow rule by attribute for the given user
531 #
536 #
532 # Examples:
537 # Examples:
533 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
538 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
534 def workflow_rule_by_attribute(user=nil)
539 def workflow_rule_by_attribute(user=nil)
535 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
540 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
536
541
537 user_real = user || User.current
542 user_real = user || User.current
538 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
543 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
539 roles = roles.select(&:consider_workflow?)
544 roles = roles.select(&:consider_workflow?)
540 return {} if roles.empty?
545 return {} if roles.empty?
541
546
542 result = {}
547 result = {}
543 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
548 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
544 if workflow_permissions.any?
549 if workflow_permissions.any?
545 workflow_rules = workflow_permissions.inject({}) do |h, wp|
550 workflow_rules = workflow_permissions.inject({}) do |h, wp|
546 h[wp.field_name] ||= []
551 h[wp.field_name] ||= []
547 h[wp.field_name] << wp.rule
552 h[wp.field_name] << wp.rule
548 h
553 h
549 end
554 end
550 workflow_rules.each do |attr, rules|
555 workflow_rules.each do |attr, rules|
551 next if rules.size < roles.size
556 next if rules.size < roles.size
552 uniq_rules = rules.uniq
557 uniq_rules = rules.uniq
553 if uniq_rules.size == 1
558 if uniq_rules.size == 1
554 result[attr] = uniq_rules.first
559 result[attr] = uniq_rules.first
555 else
560 else
556 result[attr] = 'required'
561 result[attr] = 'required'
557 end
562 end
558 end
563 end
559 end
564 end
560 @workflow_rule_by_attribute = result if user.nil?
565 @workflow_rule_by_attribute = result if user.nil?
561 result
566 result
562 end
567 end
563 private :workflow_rule_by_attribute
568 private :workflow_rule_by_attribute
564
569
565 def done_ratio
570 def done_ratio
566 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
571 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
567 status.default_done_ratio
572 status.default_done_ratio
568 else
573 else
569 read_attribute(:done_ratio)
574 read_attribute(:done_ratio)
570 end
575 end
571 end
576 end
572
577
573 def self.use_status_for_done_ratio?
578 def self.use_status_for_done_ratio?
574 Setting.issue_done_ratio == 'issue_status'
579 Setting.issue_done_ratio == 'issue_status'
575 end
580 end
576
581
577 def self.use_field_for_done_ratio?
582 def self.use_field_for_done_ratio?
578 Setting.issue_done_ratio == 'issue_field'
583 Setting.issue_done_ratio == 'issue_field'
579 end
584 end
580
585
581 def validate_issue
586 def validate_issue
582 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
587 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
583 errors.add :due_date, :greater_than_start_date
588 errors.add :due_date, :greater_than_start_date
584 end
589 end
585
590
586 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
591 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
587 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
592 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
588 end
593 end
589
594
590 if fixed_version
595 if fixed_version
591 if !assignable_versions.include?(fixed_version)
596 if !assignable_versions.include?(fixed_version)
592 errors.add :fixed_version_id, :inclusion
597 errors.add :fixed_version_id, :inclusion
593 elsif reopening? && fixed_version.closed?
598 elsif reopening? && fixed_version.closed?
594 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
599 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
595 end
600 end
596 end
601 end
597
602
598 # Checks that the issue can not be added/moved to a disabled tracker
603 # Checks that the issue can not be added/moved to a disabled tracker
599 if project && (tracker_id_changed? || project_id_changed?)
604 if project && (tracker_id_changed? || project_id_changed?)
600 unless project.trackers.include?(tracker)
605 unless project.trackers.include?(tracker)
601 errors.add :tracker_id, :inclusion
606 errors.add :tracker_id, :inclusion
602 end
607 end
603 end
608 end
604
609
605 # Checks parent issue assignment
610 # Checks parent issue assignment
606 if @invalid_parent_issue_id.present?
611 if @invalid_parent_issue_id.present?
607 errors.add :parent_issue_id, :invalid
612 errors.add :parent_issue_id, :invalid
608 elsif @parent_issue
613 elsif @parent_issue
609 if !valid_parent_project?(@parent_issue)
614 if !valid_parent_project?(@parent_issue)
610 errors.add :parent_issue_id, :invalid
615 errors.add :parent_issue_id, :invalid
611 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
616 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
612 errors.add :parent_issue_id, :invalid
617 errors.add :parent_issue_id, :invalid
613 elsif !new_record?
618 elsif !new_record?
614 # moving an existing issue
619 # moving an existing issue
615 if move_possible?(@parent_issue)
620 if move_possible?(@parent_issue)
616 # move accepted
621 # move accepted
617 else
622 else
618 errors.add :parent_issue_id, :invalid
623 errors.add :parent_issue_id, :invalid
619 end
624 end
620 end
625 end
621 end
626 end
622 end
627 end
623
628
624 # Validates the issue against additional workflow requirements
629 # Validates the issue against additional workflow requirements
625 def validate_required_fields
630 def validate_required_fields
626 user = new_record? ? author : current_journal.try(:user)
631 user = new_record? ? author : current_journal.try(:user)
627
632
628 required_attribute_names(user).each do |attribute|
633 required_attribute_names(user).each do |attribute|
629 if attribute =~ /^\d+$/
634 if attribute =~ /^\d+$/
630 attribute = attribute.to_i
635 attribute = attribute.to_i
631 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
636 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
632 if v && v.value.blank?
637 if v && v.value.blank?
633 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
638 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
634 end
639 end
635 else
640 else
636 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
641 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
637 errors.add attribute, :blank
642 errors.add attribute, :blank
638 end
643 end
639 end
644 end
640 end
645 end
641 end
646 end
642
647
643 # Overrides Redmine::Acts::Customizable::InstanceMethods#validate_custom_field_values
648 # Overrides Redmine::Acts::Customizable::InstanceMethods#validate_custom_field_values
644 # so that custom values that are not editable are not validated (eg. a custom field that
649 # so that custom values that are not editable are not validated (eg. a custom field that
645 # is marked as required should not trigger a validation error if the user is not allowed
650 # is marked as required should not trigger a validation error if the user is not allowed
646 # to edit this field).
651 # to edit this field).
647 def validate_custom_field_values
652 def validate_custom_field_values
648 user = new_record? ? author : current_journal.try(:user)
653 user = new_record? ? author : current_journal.try(:user)
649 if new_record? || custom_field_values_changed?
654 if new_record? || custom_field_values_changed?
650 editable_custom_field_values(user).each(&:validate_value)
655 editable_custom_field_values(user).each(&:validate_value)
651 end
656 end
652 end
657 end
653
658
654 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
659 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
655 # even if the user turns off the setting later
660 # even if the user turns off the setting later
656 def update_done_ratio_from_issue_status
661 def update_done_ratio_from_issue_status
657 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
662 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
658 self.done_ratio = status.default_done_ratio
663 self.done_ratio = status.default_done_ratio
659 end
664 end
660 end
665 end
661
666
662 def init_journal(user, notes = "")
667 def init_journal(user, notes = "")
663 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
668 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
664 end
669 end
665
670
666 # Returns the current journal or nil if it's not initialized
671 # Returns the current journal or nil if it's not initialized
667 def current_journal
672 def current_journal
668 @current_journal
673 @current_journal
669 end
674 end
670
675
671 # Returns the names of attributes that are journalized when updating the issue
676 # Returns the names of attributes that are journalized when updating the issue
672 def journalized_attribute_names
677 def journalized_attribute_names
673 Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
678 Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
674 end
679 end
675
680
676 # Returns the id of the last journal or nil
681 # Returns the id of the last journal or nil
677 def last_journal_id
682 def last_journal_id
678 if new_record?
683 if new_record?
679 nil
684 nil
680 else
685 else
681 journals.maximum(:id)
686 journals.maximum(:id)
682 end
687 end
683 end
688 end
684
689
685 # Returns a scope for journals that have an id greater than journal_id
690 # Returns a scope for journals that have an id greater than journal_id
686 def journals_after(journal_id)
691 def journals_after(journal_id)
687 scope = journals.reorder("#{Journal.table_name}.id ASC")
692 scope = journals.reorder("#{Journal.table_name}.id ASC")
688 if journal_id.present?
693 if journal_id.present?
689 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
694 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
690 end
695 end
691 scope
696 scope
692 end
697 end
693
698
694 # Returns the initial status of the issue
699 # Returns the initial status of the issue
695 # Returns nil for a new issue
700 # Returns nil for a new issue
696 def status_was
701 def status_was
697 if status_id_changed?
702 if status_id_changed?
698 if status_id_was.to_i > 0
703 if status_id_was.to_i > 0
699 @status_was ||= IssueStatus.find_by_id(status_id_was)
704 @status_was ||= IssueStatus.find_by_id(status_id_was)
700 end
705 end
701 else
706 else
702 @status_was ||= status
707 @status_was ||= status
703 end
708 end
704 end
709 end
705
710
706 # Return true if the issue is closed, otherwise false
711 # Return true if the issue is closed, otherwise false
707 def closed?
712 def closed?
708 status.present? && status.is_closed?
713 status.present? && status.is_closed?
709 end
714 end
710
715
711 # Returns true if the issue was closed when loaded
716 # Returns true if the issue was closed when loaded
712 def was_closed?
717 def was_closed?
713 status_was.present? && status_was.is_closed?
718 status_was.present? && status_was.is_closed?
714 end
719 end
715
720
716 # Return true if the issue is being reopened
721 # Return true if the issue is being reopened
717 def reopening?
722 def reopening?
718 if new_record?
723 if new_record?
719 false
724 false
720 else
725 else
721 status_id_changed? && !closed? && was_closed?
726 status_id_changed? && !closed? && was_closed?
722 end
727 end
723 end
728 end
724 alias :reopened? :reopening?
729 alias :reopened? :reopening?
725
730
726 # Return true if the issue is being closed
731 # Return true if the issue is being closed
727 def closing?
732 def closing?
728 if new_record?
733 if new_record?
729 closed?
734 closed?
730 else
735 else
731 status_id_changed? && closed? && !was_closed?
736 status_id_changed? && closed? && !was_closed?
732 end
737 end
733 end
738 end
734
739
735 # Returns true if the issue is overdue
740 # Returns true if the issue is overdue
736 def overdue?
741 def overdue?
737 due_date.present? && (due_date < Date.today) && !closed?
742 due_date.present? && (due_date < Date.today) && !closed?
738 end
743 end
739
744
740 # Is the amount of work done less than it should for the due date
745 # Is the amount of work done less than it should for the due date
741 def behind_schedule?
746 def behind_schedule?
742 return false if start_date.nil? || due_date.nil?
747 return false if start_date.nil? || due_date.nil?
743 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
748 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
744 return done_date <= Date.today
749 return done_date <= Date.today
745 end
750 end
746
751
747 # Does this issue have children?
752 # Does this issue have children?
748 def children?
753 def children?
749 !leaf?
754 !leaf?
750 end
755 end
751
756
752 # Users the issue can be assigned to
757 # Users the issue can be assigned to
753 def assignable_users
758 def assignable_users
754 users = project.assignable_users.to_a
759 users = project.assignable_users.to_a
755 users << author if author
760 users << author if author
756 users << assigned_to if assigned_to
761 users << assigned_to if assigned_to
757 users.uniq.sort
762 users.uniq.sort
758 end
763 end
759
764
760 # Versions that the issue can be assigned to
765 # Versions that the issue can be assigned to
761 def assignable_versions
766 def assignable_versions
762 return @assignable_versions if @assignable_versions
767 return @assignable_versions if @assignable_versions
763
768
764 versions = project.shared_versions.open.to_a
769 versions = project.shared_versions.open.to_a
765 if fixed_version
770 if fixed_version
766 if fixed_version_id_changed?
771 if fixed_version_id_changed?
767 # nothing to do
772 # nothing to do
768 elsif project_id_changed?
773 elsif project_id_changed?
769 if project.shared_versions.include?(fixed_version)
774 if project.shared_versions.include?(fixed_version)
770 versions << fixed_version
775 versions << fixed_version
771 end
776 end
772 else
777 else
773 versions << fixed_version
778 versions << fixed_version
774 end
779 end
775 end
780 end
776 @assignable_versions = versions.uniq.sort
781 @assignable_versions = versions.uniq.sort
777 end
782 end
778
783
779 # Returns true if this issue is blocked by another issue that is still open
784 # Returns true if this issue is blocked by another issue that is still open
780 def blocked?
785 def blocked?
781 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
786 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
782 end
787 end
783
788
784 # Returns the default status of the issue based on its tracker
789 # Returns the default status of the issue based on its tracker
785 # Returns nil if tracker is nil
790 # Returns nil if tracker is nil
786 def default_status
791 def default_status
787 tracker.try(:default_status)
792 tracker.try(:default_status)
788 end
793 end
789
794
790 # Returns an array of statuses that user is able to apply
795 # Returns an array of statuses that user is able to apply
791 def new_statuses_allowed_to(user=User.current, include_default=false)
796 def new_statuses_allowed_to(user=User.current, include_default=false)
792 if new_record? && @copied_from
797 if new_record? && @copied_from
793 [default_status, @copied_from.status].compact.uniq.sort
798 [default_status, @copied_from.status].compact.uniq.sort
794 else
799 else
795 initial_status = nil
800 initial_status = nil
796 if new_record?
801 if new_record?
797 initial_status = default_status
802 initial_status = default_status
798 elsif tracker_id_changed?
803 elsif tracker_id_changed?
799 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
804 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
800 initial_status = default_status
805 initial_status = default_status
801 elsif tracker.issue_status_ids.include?(status_id_was)
806 elsif tracker.issue_status_ids.include?(status_id_was)
802 initial_status = IssueStatus.find_by_id(status_id_was)
807 initial_status = IssueStatus.find_by_id(status_id_was)
803 else
808 else
804 initial_status = default_status
809 initial_status = default_status
805 end
810 end
806 else
811 else
807 initial_status = status_was
812 initial_status = status_was
808 end
813 end
809
814
810 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
815 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
811 assignee_transitions_allowed = initial_assigned_to_id.present? &&
816 assignee_transitions_allowed = initial_assigned_to_id.present? &&
812 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
817 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
813
818
814 statuses = []
819 statuses = []
815 if initial_status
820 if initial_status
816 statuses += initial_status.find_new_statuses_allowed_to(
821 statuses += initial_status.find_new_statuses_allowed_to(
817 user.admin ? Role.all.to_a : user.roles_for_project(project),
822 user.admin ? Role.all.to_a : user.roles_for_project(project),
818 tracker,
823 tracker,
819 author == user,
824 author == user,
820 assignee_transitions_allowed
825 assignee_transitions_allowed
821 )
826 )
822 end
827 end
823 statuses << initial_status unless statuses.empty?
828 statuses << initial_status unless statuses.empty?
824 statuses << default_status if include_default
829 statuses << default_status if include_default
825 statuses = statuses.compact.uniq.sort
830 statuses = statuses.compact.uniq.sort
826 if blocked?
831 if blocked?
827 statuses.reject!(&:is_closed?)
832 statuses.reject!(&:is_closed?)
828 end
833 end
829 statuses
834 statuses
830 end
835 end
831 end
836 end
832
837
833 # Returns the previous assignee (user or group) if changed
838 # Returns the previous assignee (user or group) if changed
834 def assigned_to_was
839 def assigned_to_was
835 # assigned_to_id_was is reset before after_save callbacks
840 # assigned_to_id_was is reset before after_save callbacks
836 user_id = @previous_assigned_to_id || assigned_to_id_was
841 user_id = @previous_assigned_to_id || assigned_to_id_was
837 if user_id && user_id != assigned_to_id
842 if user_id && user_id != assigned_to_id
838 @assigned_to_was ||= Principal.find_by_id(user_id)
843 @assigned_to_was ||= Principal.find_by_id(user_id)
839 end
844 end
840 end
845 end
841
846
842 # Returns the users that should be notified
847 # Returns the users that should be notified
843 def notified_users
848 def notified_users
844 notified = []
849 notified = []
845 # Author and assignee are always notified unless they have been
850 # Author and assignee are always notified unless they have been
846 # locked or don't want to be notified
851 # locked or don't want to be notified
847 notified << author if author
852 notified << author if author
848 if assigned_to
853 if assigned_to
849 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
854 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
850 end
855 end
851 if assigned_to_was
856 if assigned_to_was
852 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
857 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
853 end
858 end
854 notified = notified.select {|u| u.active? && u.notify_about?(self)}
859 notified = notified.select {|u| u.active? && u.notify_about?(self)}
855
860
856 notified += project.notified_users
861 notified += project.notified_users
857 notified.uniq!
862 notified.uniq!
858 # Remove users that can not view the issue
863 # Remove users that can not view the issue
859 notified.reject! {|user| !visible?(user)}
864 notified.reject! {|user| !visible?(user)}
860 notified
865 notified
861 end
866 end
862
867
863 # Returns the email addresses that should be notified
868 # Returns the email addresses that should be notified
864 def recipients
869 def recipients
865 notified_users.collect(&:mail)
870 notified_users.collect(&:mail)
866 end
871 end
867
872
868 def each_notification(users, &block)
873 def each_notification(users, &block)
869 if users.any?
874 if users.any?
870 if custom_field_values.detect {|value| !value.custom_field.visible?}
875 if custom_field_values.detect {|value| !value.custom_field.visible?}
871 users_by_custom_field_visibility = users.group_by do |user|
876 users_by_custom_field_visibility = users.group_by do |user|
872 visible_custom_field_values(user).map(&:custom_field_id).sort
877 visible_custom_field_values(user).map(&:custom_field_id).sort
873 end
878 end
874 users_by_custom_field_visibility.values.each do |users|
879 users_by_custom_field_visibility.values.each do |users|
875 yield(users)
880 yield(users)
876 end
881 end
877 else
882 else
878 yield(users)
883 yield(users)
879 end
884 end
880 end
885 end
881 end
886 end
882
887
883 # Returns the number of hours spent on this issue
888 # Returns the number of hours spent on this issue
884 def spent_hours
889 def spent_hours
885 @spent_hours ||= time_entries.sum(:hours) || 0
890 @spent_hours ||= time_entries.sum(:hours) || 0
886 end
891 end
887
892
888 # Returns the total number of hours spent on this issue and its descendants
893 # Returns the total number of hours spent on this issue and its descendants
889 #
894 #
890 # Example:
895 # Example:
891 # spent_hours => 0.0
896 # spent_hours => 0.0
892 # spent_hours => 50.2
897 # spent_hours => 50.2
893 def total_spent_hours
898 def total_spent_hours
894 @total_spent_hours ||=
899 @total_spent_hours ||=
895 self_and_descendants.
900 self_and_descendants.
896 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
901 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
897 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
902 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
898 end
903 end
899
904
900 def relations
905 def relations
901 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
906 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
902 end
907 end
903
908
904 # Preloads relations for a collection of issues
909 # Preloads relations for a collection of issues
905 def self.load_relations(issues)
910 def self.load_relations(issues)
906 if issues.any?
911 if issues.any?
907 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
912 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
908 issues.each do |issue|
913 issues.each do |issue|
909 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
914 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
910 end
915 end
911 end
916 end
912 end
917 end
913
918
914 # Preloads visible spent time for a collection of issues
919 # Preloads visible spent time for a collection of issues
915 def self.load_visible_spent_hours(issues, user=User.current)
920 def self.load_visible_spent_hours(issues, user=User.current)
916 if issues.any?
921 if issues.any?
917 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
922 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
918 issues.each do |issue|
923 issues.each do |issue|
919 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
924 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
920 end
925 end
921 end
926 end
922 end
927 end
923
928
924 # Preloads visible relations for a collection of issues
929 # Preloads visible relations for a collection of issues
925 def self.load_visible_relations(issues, user=User.current)
930 def self.load_visible_relations(issues, user=User.current)
926 if issues.any?
931 if issues.any?
927 issue_ids = issues.map(&:id)
932 issue_ids = issues.map(&:id)
928 # Relations with issue_from in given issues and visible issue_to
933 # Relations with issue_from in given issues and visible issue_to
929 relations_from = IssueRelation.joins(:issue_to => :project).
934 relations_from = IssueRelation.joins(:issue_to => :project).
930 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
935 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
931 # Relations with issue_to in given issues and visible issue_from
936 # Relations with issue_to in given issues and visible issue_from
932 relations_to = IssueRelation.joins(:issue_from => :project).
937 relations_to = IssueRelation.joins(:issue_from => :project).
933 where(visible_condition(user)).
938 where(visible_condition(user)).
934 where(:issue_to_id => issue_ids).to_a
939 where(:issue_to_id => issue_ids).to_a
935 issues.each do |issue|
940 issues.each do |issue|
936 relations =
941 relations =
937 relations_from.select {|relation| relation.issue_from_id == issue.id} +
942 relations_from.select {|relation| relation.issue_from_id == issue.id} +
938 relations_to.select {|relation| relation.issue_to_id == issue.id}
943 relations_to.select {|relation| relation.issue_to_id == issue.id}
939
944
940 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
945 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
941 end
946 end
942 end
947 end
943 end
948 end
944
949
945 # Finds an issue relation given its id.
950 # Finds an issue relation given its id.
946 def find_relation(relation_id)
951 def find_relation(relation_id)
947 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
952 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
948 end
953 end
949
954
950 # Returns all the other issues that depend on the issue
955 # Returns all the other issues that depend on the issue
951 # The algorithm is a modified breadth first search (bfs)
956 # The algorithm is a modified breadth first search (bfs)
952 def all_dependent_issues(except=[])
957 def all_dependent_issues(except=[])
953 # The found dependencies
958 # The found dependencies
954 dependencies = []
959 dependencies = []
955
960
956 # The visited flag for every node (issue) used by the breadth first search
961 # The visited flag for every node (issue) used by the breadth first search
957 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
962 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
958
963
959 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
964 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
960 # the issue when it is processed.
965 # the issue when it is processed.
961
966
962 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
967 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
963 # but its children will not be added to the queue when it is processed.
968 # but its children will not be added to the queue when it is processed.
964
969
965 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
970 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
966 # the queue, but its children have not been added.
971 # the queue, but its children have not been added.
967
972
968 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
973 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
969 # the children still need to be processed.
974 # the children still need to be processed.
970
975
971 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
976 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
972 # added as dependent issues. It needs no further processing.
977 # added as dependent issues. It needs no further processing.
973
978
974 issue_status = Hash.new(eNOT_DISCOVERED)
979 issue_status = Hash.new(eNOT_DISCOVERED)
975
980
976 # The queue
981 # The queue
977 queue = []
982 queue = []
978
983
979 # Initialize the bfs, add start node (self) to the queue
984 # Initialize the bfs, add start node (self) to the queue
980 queue << self
985 queue << self
981 issue_status[self] = ePROCESS_ALL
986 issue_status[self] = ePROCESS_ALL
982
987
983 while (!queue.empty?) do
988 while (!queue.empty?) do
984 current_issue = queue.shift
989 current_issue = queue.shift
985 current_issue_status = issue_status[current_issue]
990 current_issue_status = issue_status[current_issue]
986 dependencies << current_issue
991 dependencies << current_issue
987
992
988 # Add parent to queue, if not already in it.
993 # Add parent to queue, if not already in it.
989 parent = current_issue.parent
994 parent = current_issue.parent
990 parent_status = issue_status[parent]
995 parent_status = issue_status[parent]
991
996
992 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
997 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
993 queue << parent
998 queue << parent
994 issue_status[parent] = ePROCESS_RELATIONS_ONLY
999 issue_status[parent] = ePROCESS_RELATIONS_ONLY
995 end
1000 end
996
1001
997 # Add children to queue, but only if they are not already in it and
1002 # Add children to queue, but only if they are not already in it and
998 # the children of the current node need to be processed.
1003 # the children of the current node need to be processed.
999 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
1004 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
1000 current_issue.children.each do |child|
1005 current_issue.children.each do |child|
1001 next if except.include?(child)
1006 next if except.include?(child)
1002
1007
1003 if (issue_status[child] == eNOT_DISCOVERED)
1008 if (issue_status[child] == eNOT_DISCOVERED)
1004 queue << child
1009 queue << child
1005 issue_status[child] = ePROCESS_ALL
1010 issue_status[child] = ePROCESS_ALL
1006 elsif (issue_status[child] == eRELATIONS_PROCESSED)
1011 elsif (issue_status[child] == eRELATIONS_PROCESSED)
1007 queue << child
1012 queue << child
1008 issue_status[child] = ePROCESS_CHILDREN_ONLY
1013 issue_status[child] = ePROCESS_CHILDREN_ONLY
1009 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1014 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1010 queue << child
1015 queue << child
1011 issue_status[child] = ePROCESS_ALL
1016 issue_status[child] = ePROCESS_ALL
1012 end
1017 end
1013 end
1018 end
1014 end
1019 end
1015
1020
1016 # Add related issues to the queue, if they are not already in it.
1021 # Add related issues to the queue, if they are not already in it.
1017 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1022 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1018 next if except.include?(related_issue)
1023 next if except.include?(related_issue)
1019
1024
1020 if (issue_status[related_issue] == eNOT_DISCOVERED)
1025 if (issue_status[related_issue] == eNOT_DISCOVERED)
1021 queue << related_issue
1026 queue << related_issue
1022 issue_status[related_issue] = ePROCESS_ALL
1027 issue_status[related_issue] = ePROCESS_ALL
1023 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1028 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1024 queue << related_issue
1029 queue << related_issue
1025 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1030 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1026 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1031 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1027 queue << related_issue
1032 queue << related_issue
1028 issue_status[related_issue] = ePROCESS_ALL
1033 issue_status[related_issue] = ePROCESS_ALL
1029 end
1034 end
1030 end
1035 end
1031
1036
1032 # Set new status for current issue
1037 # Set new status for current issue
1033 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1038 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1034 issue_status[current_issue] = eALL_PROCESSED
1039 issue_status[current_issue] = eALL_PROCESSED
1035 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1040 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1036 issue_status[current_issue] = eRELATIONS_PROCESSED
1041 issue_status[current_issue] = eRELATIONS_PROCESSED
1037 end
1042 end
1038 end # while
1043 end # while
1039
1044
1040 # Remove the issues from the "except" parameter from the result array
1045 # Remove the issues from the "except" parameter from the result array
1041 dependencies -= except
1046 dependencies -= except
1042 dependencies.delete(self)
1047 dependencies.delete(self)
1043
1048
1044 dependencies
1049 dependencies
1045 end
1050 end
1046
1051
1047 # Returns an array of issues that duplicate this one
1052 # Returns an array of issues that duplicate this one
1048 def duplicates
1053 def duplicates
1049 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1054 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1050 end
1055 end
1051
1056
1052 # Returns the due date or the target due date if any
1057 # Returns the due date or the target due date if any
1053 # Used on gantt chart
1058 # Used on gantt chart
1054 def due_before
1059 def due_before
1055 due_date || (fixed_version ? fixed_version.effective_date : nil)
1060 due_date || (fixed_version ? fixed_version.effective_date : nil)
1056 end
1061 end
1057
1062
1058 # Returns the time scheduled for this issue.
1063 # Returns the time scheduled for this issue.
1059 #
1064 #
1060 # Example:
1065 # Example:
1061 # Start Date: 2/26/09, End Date: 3/04/09
1066 # Start Date: 2/26/09, End Date: 3/04/09
1062 # duration => 6
1067 # duration => 6
1063 def duration
1068 def duration
1064 (start_date && due_date) ? due_date - start_date : 0
1069 (start_date && due_date) ? due_date - start_date : 0
1065 end
1070 end
1066
1071
1067 # Returns the duration in working days
1072 # Returns the duration in working days
1068 def working_duration
1073 def working_duration
1069 (start_date && due_date) ? working_days(start_date, due_date) : 0
1074 (start_date && due_date) ? working_days(start_date, due_date) : 0
1070 end
1075 end
1071
1076
1072 def soonest_start(reload=false)
1077 def soonest_start(reload=false)
1073 @soonest_start = nil if reload
1078 @soonest_start = nil if reload
1074 @soonest_start ||= (
1079 @soonest_start ||= (
1075 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1080 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1076 [(@parent_issue || parent).try(:soonest_start)]
1081 [(@parent_issue || parent).try(:soonest_start)]
1077 ).compact.max
1082 ).compact.max
1078 end
1083 end
1079
1084
1080 # Sets start_date on the given date or the next working day
1085 # Sets start_date on the given date or the next working day
1081 # and changes due_date to keep the same working duration.
1086 # and changes due_date to keep the same working duration.
1082 def reschedule_on(date)
1087 def reschedule_on(date)
1083 wd = working_duration
1088 wd = working_duration
1084 date = next_working_date(date)
1089 date = next_working_date(date)
1085 self.start_date = date
1090 self.start_date = date
1086 self.due_date = add_working_days(date, wd)
1091 self.due_date = add_working_days(date, wd)
1087 end
1092 end
1088
1093
1089 # Reschedules the issue on the given date or the next working day and saves the record.
1094 # Reschedules the issue on the given date or the next working day and saves the record.
1090 # If the issue is a parent task, this is done by rescheduling its subtasks.
1095 # If the issue is a parent task, this is done by rescheduling its subtasks.
1091 def reschedule_on!(date)
1096 def reschedule_on!(date)
1092 return if date.nil?
1097 return if date.nil?
1093 if leaf?
1098 if leaf?
1094 if start_date.nil? || start_date != date
1099 if start_date.nil? || start_date != date
1095 if start_date && start_date > date
1100 if start_date && start_date > date
1096 # Issue can not be moved earlier than its soonest start date
1101 # Issue can not be moved earlier than its soonest start date
1097 date = [soonest_start(true), date].compact.max
1102 date = [soonest_start(true), date].compact.max
1098 end
1103 end
1099 reschedule_on(date)
1104 reschedule_on(date)
1100 begin
1105 begin
1101 save
1106 save
1102 rescue ActiveRecord::StaleObjectError
1107 rescue ActiveRecord::StaleObjectError
1103 reload
1108 reload
1104 reschedule_on(date)
1109 reschedule_on(date)
1105 save
1110 save
1106 end
1111 end
1107 end
1112 end
1108 else
1113 else
1109 leaves.each do |leaf|
1114 leaves.each do |leaf|
1110 if leaf.start_date
1115 if leaf.start_date
1111 # Only move subtask if it starts at the same date as the parent
1116 # Only move subtask if it starts at the same date as the parent
1112 # or if it starts before the given date
1117 # or if it starts before the given date
1113 if start_date == leaf.start_date || date > leaf.start_date
1118 if start_date == leaf.start_date || date > leaf.start_date
1114 leaf.reschedule_on!(date)
1119 leaf.reschedule_on!(date)
1115 end
1120 end
1116 else
1121 else
1117 leaf.reschedule_on!(date)
1122 leaf.reschedule_on!(date)
1118 end
1123 end
1119 end
1124 end
1120 end
1125 end
1121 end
1126 end
1122
1127
1123 def <=>(issue)
1128 def <=>(issue)
1124 if issue.nil?
1129 if issue.nil?
1125 -1
1130 -1
1126 elsif root_id != issue.root_id
1131 elsif root_id != issue.root_id
1127 (root_id || 0) <=> (issue.root_id || 0)
1132 (root_id || 0) <=> (issue.root_id || 0)
1128 else
1133 else
1129 (lft || 0) <=> (issue.lft || 0)
1134 (lft || 0) <=> (issue.lft || 0)
1130 end
1135 end
1131 end
1136 end
1132
1137
1133 def to_s
1138 def to_s
1134 "#{tracker} ##{id}: #{subject}"
1139 "#{tracker} ##{id}: #{subject}"
1135 end
1140 end
1136
1141
1137 # Returns a string of css classes that apply to the issue
1142 # Returns a string of css classes that apply to the issue
1138 def css_classes(user=User.current)
1143 def css_classes(user=User.current)
1139 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1144 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1140 s << ' closed' if closed?
1145 s << ' closed' if closed?
1141 s << ' overdue' if overdue?
1146 s << ' overdue' if overdue?
1142 s << ' child' if child?
1147 s << ' child' if child?
1143 s << ' parent' unless leaf?
1148 s << ' parent' unless leaf?
1144 s << ' private' if is_private?
1149 s << ' private' if is_private?
1145 if user.logged?
1150 if user.logged?
1146 s << ' created-by-me' if author_id == user.id
1151 s << ' created-by-me' if author_id == user.id
1147 s << ' assigned-to-me' if assigned_to_id == user.id
1152 s << ' assigned-to-me' if assigned_to_id == user.id
1148 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1153 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1149 end
1154 end
1150 s
1155 s
1151 end
1156 end
1152
1157
1153 # Unassigns issues from +version+ if it's no longer shared with issue's project
1158 # Unassigns issues from +version+ if it's no longer shared with issue's project
1154 def self.update_versions_from_sharing_change(version)
1159 def self.update_versions_from_sharing_change(version)
1155 # Update issues assigned to the version
1160 # Update issues assigned to the version
1156 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1161 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1157 end
1162 end
1158
1163
1159 # Unassigns issues from versions that are no longer shared
1164 # Unassigns issues from versions that are no longer shared
1160 # after +project+ was moved
1165 # after +project+ was moved
1161 def self.update_versions_from_hierarchy_change(project)
1166 def self.update_versions_from_hierarchy_change(project)
1162 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1167 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1163 # Update issues of the moved projects and issues assigned to a version of a moved project
1168 # Update issues of the moved projects and issues assigned to a version of a moved project
1164 Issue.update_versions(
1169 Issue.update_versions(
1165 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1170 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1166 moved_project_ids, moved_project_ids]
1171 moved_project_ids, moved_project_ids]
1167 )
1172 )
1168 end
1173 end
1169
1174
1170 def parent_issue_id=(arg)
1175 def parent_issue_id=(arg)
1171 s = arg.to_s.strip.presence
1176 s = arg.to_s.strip.presence
1172 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1177 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1173 @invalid_parent_issue_id = nil
1178 @invalid_parent_issue_id = nil
1174 elsif s.blank?
1179 elsif s.blank?
1175 @parent_issue = nil
1180 @parent_issue = nil
1176 @invalid_parent_issue_id = nil
1181 @invalid_parent_issue_id = nil
1177 else
1182 else
1178 @parent_issue = nil
1183 @parent_issue = nil
1179 @invalid_parent_issue_id = arg
1184 @invalid_parent_issue_id = arg
1180 end
1185 end
1181 end
1186 end
1182
1187
1183 def parent_issue_id
1188 def parent_issue_id
1184 if @invalid_parent_issue_id
1189 if @invalid_parent_issue_id
1185 @invalid_parent_issue_id
1190 @invalid_parent_issue_id
1186 elsif instance_variable_defined? :@parent_issue
1191 elsif instance_variable_defined? :@parent_issue
1187 @parent_issue.nil? ? nil : @parent_issue.id
1192 @parent_issue.nil? ? nil : @parent_issue.id
1188 else
1193 else
1189 parent_id
1194 parent_id
1190 end
1195 end
1191 end
1196 end
1192
1197
1193 def set_parent_id
1198 def set_parent_id
1194 self.parent_id = parent_issue_id
1199 self.parent_id = parent_issue_id
1195 end
1200 end
1196
1201
1197 # Returns true if issue's project is a valid
1202 # Returns true if issue's project is a valid
1198 # parent issue project
1203 # parent issue project
1199 def valid_parent_project?(issue=parent)
1204 def valid_parent_project?(issue=parent)
1200 return true if issue.nil? || issue.project_id == project_id
1205 return true if issue.nil? || issue.project_id == project_id
1201
1206
1202 case Setting.cross_project_subtasks
1207 case Setting.cross_project_subtasks
1203 when 'system'
1208 when 'system'
1204 true
1209 true
1205 when 'tree'
1210 when 'tree'
1206 issue.project.root == project.root
1211 issue.project.root == project.root
1207 when 'hierarchy'
1212 when 'hierarchy'
1208 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1213 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1209 when 'descendants'
1214 when 'descendants'
1210 issue.project.is_or_is_ancestor_of?(project)
1215 issue.project.is_or_is_ancestor_of?(project)
1211 else
1216 else
1212 false
1217 false
1213 end
1218 end
1214 end
1219 end
1215
1220
1216 # Returns an issue scope based on project and scope
1221 # Returns an issue scope based on project and scope
1217 def self.cross_project_scope(project, scope=nil)
1222 def self.cross_project_scope(project, scope=nil)
1218 if project.nil?
1223 if project.nil?
1219 return Issue
1224 return Issue
1220 end
1225 end
1221 case scope
1226 case scope
1222 when 'all', 'system'
1227 when 'all', 'system'
1223 Issue
1228 Issue
1224 when 'tree'
1229 when 'tree'
1225 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1230 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1226 :lft => project.root.lft, :rgt => project.root.rgt)
1231 :lft => project.root.lft, :rgt => project.root.rgt)
1227 when 'hierarchy'
1232 when 'hierarchy'
1228 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1233 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1229 :lft => project.lft, :rgt => project.rgt)
1234 :lft => project.lft, :rgt => project.rgt)
1230 when 'descendants'
1235 when 'descendants'
1231 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1236 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1232 :lft => project.lft, :rgt => project.rgt)
1237 :lft => project.lft, :rgt => project.rgt)
1233 else
1238 else
1234 Issue.where(:project_id => project.id)
1239 Issue.where(:project_id => project.id)
1235 end
1240 end
1236 end
1241 end
1237
1242
1238 def self.by_tracker(project)
1243 def self.by_tracker(project)
1239 count_and_group_by(:project => project, :association => :tracker)
1244 count_and_group_by(:project => project, :association => :tracker)
1240 end
1245 end
1241
1246
1242 def self.by_version(project)
1247 def self.by_version(project)
1243 count_and_group_by(:project => project, :association => :fixed_version)
1248 count_and_group_by(:project => project, :association => :fixed_version)
1244 end
1249 end
1245
1250
1246 def self.by_priority(project)
1251 def self.by_priority(project)
1247 count_and_group_by(:project => project, :association => :priority)
1252 count_and_group_by(:project => project, :association => :priority)
1248 end
1253 end
1249
1254
1250 def self.by_category(project)
1255 def self.by_category(project)
1251 count_and_group_by(:project => project, :association => :category)
1256 count_and_group_by(:project => project, :association => :category)
1252 end
1257 end
1253
1258
1254 def self.by_assigned_to(project)
1259 def self.by_assigned_to(project)
1255 count_and_group_by(:project => project, :association => :assigned_to)
1260 count_and_group_by(:project => project, :association => :assigned_to)
1256 end
1261 end
1257
1262
1258 def self.by_author(project)
1263 def self.by_author(project)
1259 count_and_group_by(:project => project, :association => :author)
1264 count_and_group_by(:project => project, :association => :author)
1260 end
1265 end
1261
1266
1262 def self.by_subproject(project)
1267 def self.by_subproject(project)
1263 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1268 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1264 r.reject {|r| r["project_id"] == project.id.to_s}
1269 r.reject {|r| r["project_id"] == project.id.to_s}
1265 end
1270 end
1266
1271
1267 # Query generator for selecting groups of issue counts for a project
1272 # Query generator for selecting groups of issue counts for a project
1268 # based on specific criteria
1273 # based on specific criteria
1269 #
1274 #
1270 # Options
1275 # Options
1271 # * project - Project to search in.
1276 # * project - Project to search in.
1272 # * with_subprojects - Includes subprojects issues if set to true.
1277 # * with_subprojects - Includes subprojects issues if set to true.
1273 # * association - Symbol. Association for grouping.
1278 # * association - Symbol. Association for grouping.
1274 def self.count_and_group_by(options)
1279 def self.count_and_group_by(options)
1275 assoc = reflect_on_association(options[:association])
1280 assoc = reflect_on_association(options[:association])
1276 select_field = assoc.foreign_key
1281 select_field = assoc.foreign_key
1277
1282
1278 Issue.
1283 Issue.
1279 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1284 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1280 joins(:status, assoc.name).
1285 joins(:status, assoc.name).
1281 group(:status_id, :is_closed, select_field).
1286 group(:status_id, :is_closed, select_field).
1282 count.
1287 count.
1283 map do |columns, total|
1288 map do |columns, total|
1284 status_id, is_closed, field_value = columns
1289 status_id, is_closed, field_value = columns
1285 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1290 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1286 {
1291 {
1287 "status_id" => status_id.to_s,
1292 "status_id" => status_id.to_s,
1288 "closed" => is_closed,
1293 "closed" => is_closed,
1289 select_field => field_value.to_s,
1294 select_field => field_value.to_s,
1290 "total" => total.to_s
1295 "total" => total.to_s
1291 }
1296 }
1292 end
1297 end
1293 end
1298 end
1294
1299
1295 # Returns a scope of projects that user can assign the issue to
1300 # Returns a scope of projects that user can assign the issue to
1296 def allowed_target_projects(user=User.current)
1301 def allowed_target_projects(user=User.current)
1297 current_project = new_record? ? nil : project
1302 current_project = new_record? ? nil : project
1298 self.class.allowed_target_projects(user, current_project)
1303 self.class.allowed_target_projects(user, current_project)
1299 end
1304 end
1300
1305
1301 # Returns a scope of projects that user can assign issues to
1306 # Returns a scope of projects that user can assign issues to
1302 # If current_project is given, it will be included in the scope
1307 # If current_project is given, it will be included in the scope
1303 def self.allowed_target_projects(user=User.current, current_project=nil)
1308 def self.allowed_target_projects(user=User.current, current_project=nil)
1304 condition = Project.allowed_to_condition(user, :add_issues)
1309 condition = Project.allowed_to_condition(user, :add_issues)
1305 if current_project
1310 if current_project
1306 condition = ["(#{condition}) OR #{Project.table_name}.id = ?", current_project.id]
1311 condition = ["(#{condition}) OR #{Project.table_name}.id = ?", current_project.id]
1307 end
1312 end
1308 Project.where(condition)
1313 Project.where(condition)
1309 end
1314 end
1310
1315
1311 private
1316 private
1312
1317
1313 def after_project_change
1318 def after_project_change
1314 # Update project_id on related time entries
1319 # Update project_id on related time entries
1315 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1320 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1316
1321
1317 # Delete issue relations
1322 # Delete issue relations
1318 unless Setting.cross_project_issue_relations?
1323 unless Setting.cross_project_issue_relations?
1319 relations_from.clear
1324 relations_from.clear
1320 relations_to.clear
1325 relations_to.clear
1321 end
1326 end
1322
1327
1323 # Move subtasks that were in the same project
1328 # Move subtasks that were in the same project
1324 children.each do |child|
1329 children.each do |child|
1325 next unless child.project_id == project_id_was
1330 next unless child.project_id == project_id_was
1326 # Change project and keep project
1331 # Change project and keep project
1327 child.send :project=, project, true
1332 child.send :project=, project, true
1328 unless child.save
1333 unless child.save
1329 raise ActiveRecord::Rollback
1334 raise ActiveRecord::Rollback
1330 end
1335 end
1331 end
1336 end
1332 end
1337 end
1333
1338
1334 # Callback for after the creation of an issue by copy
1339 # Callback for after the creation of an issue by copy
1335 # * adds a "copied to" relation with the copied issue
1340 # * adds a "copied to" relation with the copied issue
1336 # * copies subtasks from the copied issue
1341 # * copies subtasks from the copied issue
1337 def after_create_from_copy
1342 def after_create_from_copy
1338 return unless copy? && !@after_create_from_copy_handled
1343 return unless copy? && !@after_create_from_copy_handled
1339
1344
1340 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1345 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1341 if @current_journal
1346 if @current_journal
1342 @copied_from.init_journal(@current_journal.user)
1347 @copied_from.init_journal(@current_journal.user)
1343 end
1348 end
1344 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1349 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1345 unless relation.save
1350 unless relation.save
1346 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1351 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1347 end
1352 end
1348 end
1353 end
1349
1354
1350 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1355 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1351 copy_options = (@copy_options || {}).merge(:subtasks => false)
1356 copy_options = (@copy_options || {}).merge(:subtasks => false)
1352 copied_issue_ids = {@copied_from.id => self.id}
1357 copied_issue_ids = {@copied_from.id => self.id}
1353 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1358 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1354 # Do not copy self when copying an issue as a descendant of the copied issue
1359 # Do not copy self when copying an issue as a descendant of the copied issue
1355 next if child == self
1360 next if child == self
1356 # Do not copy subtasks of issues that were not copied
1361 # Do not copy subtasks of issues that were not copied
1357 next unless copied_issue_ids[child.parent_id]
1362 next unless copied_issue_ids[child.parent_id]
1358 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1363 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1359 unless child.visible?
1364 unless child.visible?
1360 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1365 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1361 next
1366 next
1362 end
1367 end
1363 copy = Issue.new.copy_from(child, copy_options)
1368 copy = Issue.new.copy_from(child, copy_options)
1364 if @current_journal
1369 if @current_journal
1365 copy.init_journal(@current_journal.user)
1370 copy.init_journal(@current_journal.user)
1366 end
1371 end
1367 copy.author = author
1372 copy.author = author
1368 copy.project = project
1373 copy.project = project
1369 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1374 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1370 unless copy.save
1375 unless copy.save
1371 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1376 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1372 next
1377 next
1373 end
1378 end
1374 copied_issue_ids[child.id] = copy.id
1379 copied_issue_ids[child.id] = copy.id
1375 end
1380 end
1376 end
1381 end
1377 @after_create_from_copy_handled = true
1382 @after_create_from_copy_handled = true
1378 end
1383 end
1379
1384
1380 def update_nested_set_attributes
1385 def update_nested_set_attributes
1381 if parent_id_changed?
1386 if parent_id_changed?
1382 update_nested_set_attributes_on_parent_change
1387 update_nested_set_attributes_on_parent_change
1383 end
1388 end
1384 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1389 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1385 end
1390 end
1386
1391
1387 # Updates the nested set for when an existing issue is moved
1392 # Updates the nested set for when an existing issue is moved
1388 def update_nested_set_attributes_on_parent_change
1393 def update_nested_set_attributes_on_parent_change
1389 former_parent_id = parent_id_was
1394 former_parent_id = parent_id_was
1390 # delete invalid relations of all descendants
1395 # delete invalid relations of all descendants
1391 self_and_descendants.each do |issue|
1396 self_and_descendants.each do |issue|
1392 issue.relations.each do |relation|
1397 issue.relations.each do |relation|
1393 relation.destroy unless relation.valid?
1398 relation.destroy unless relation.valid?
1394 end
1399 end
1395 end
1400 end
1396 # update former parent
1401 # update former parent
1397 recalculate_attributes_for(former_parent_id) if former_parent_id
1402 recalculate_attributes_for(former_parent_id) if former_parent_id
1398 end
1403 end
1399
1404
1400 def update_parent_attributes
1405 def update_parent_attributes
1401 if parent_id
1406 if parent_id
1402 recalculate_attributes_for(parent_id)
1407 recalculate_attributes_for(parent_id)
1403 association(:parent).reset
1408 association(:parent).reset
1404 end
1409 end
1405 end
1410 end
1406
1411
1407 def recalculate_attributes_for(issue_id)
1412 def recalculate_attributes_for(issue_id)
1408 if issue_id && p = Issue.find_by_id(issue_id)
1413 if issue_id && p = Issue.find_by_id(issue_id)
1409 # priority = highest priority of children
1414 # priority = highest priority of children
1410 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1415 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1411 p.priority = IssuePriority.find_by_position(priority_position)
1416 p.priority = IssuePriority.find_by_position(priority_position)
1412 end
1417 end
1413
1418
1414 # start/due dates = lowest/highest dates of children
1419 # start/due dates = lowest/highest dates of children
1415 p.start_date = p.children.minimum(:start_date)
1420 p.start_date = p.children.minimum(:start_date)
1416 p.due_date = p.children.maximum(:due_date)
1421 p.due_date = p.children.maximum(:due_date)
1417 if p.start_date && p.due_date && p.due_date < p.start_date
1422 if p.start_date && p.due_date && p.due_date < p.start_date
1418 p.start_date, p.due_date = p.due_date, p.start_date
1423 p.start_date, p.due_date = p.due_date, p.start_date
1419 end
1424 end
1420
1425
1421 # done ratio = weighted average ratio of leaves
1426 # done ratio = weighted average ratio of leaves
1422 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1427 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1423 leaves_count = p.leaves.count
1428 leaves_count = p.leaves.count
1424 if leaves_count > 0
1429 if leaves_count > 0
1425 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1430 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1426 if average == 0
1431 if average == 0
1427 average = 1
1432 average = 1
1428 end
1433 end
1429 done = p.leaves.joins(:status).
1434 done = p.leaves.joins(:status).
1430 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1435 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1431 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1436 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1432 progress = done / (average * leaves_count)
1437 progress = done / (average * leaves_count)
1433 p.done_ratio = progress.round
1438 p.done_ratio = progress.round
1434 end
1439 end
1435 end
1440 end
1436
1441
1437 # estimate = sum of leaves estimates
1442 # estimate = sum of leaves estimates
1438 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1443 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1439 p.estimated_hours = nil if p.estimated_hours == 0.0
1444 p.estimated_hours = nil if p.estimated_hours == 0.0
1440
1445
1441 # ancestors will be recursively updated
1446 # ancestors will be recursively updated
1442 p.save(:validate => false)
1447 p.save(:validate => false)
1443 end
1448 end
1444 end
1449 end
1445
1450
1446 # Update issues so their versions are not pointing to a
1451 # Update issues so their versions are not pointing to a
1447 # fixed_version that is not shared with the issue's project
1452 # fixed_version that is not shared with the issue's project
1448 def self.update_versions(conditions=nil)
1453 def self.update_versions(conditions=nil)
1449 # Only need to update issues with a fixed_version from
1454 # Only need to update issues with a fixed_version from
1450 # a different project and that is not systemwide shared
1455 # a different project and that is not systemwide shared
1451 Issue.joins(:project, :fixed_version).
1456 Issue.joins(:project, :fixed_version).
1452 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1457 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1453 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1458 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1454 " AND #{Version.table_name}.sharing <> 'system'").
1459 " AND #{Version.table_name}.sharing <> 'system'").
1455 where(conditions).each do |issue|
1460 where(conditions).each do |issue|
1456 next if issue.project.nil? || issue.fixed_version.nil?
1461 next if issue.project.nil? || issue.fixed_version.nil?
1457 unless issue.project.shared_versions.include?(issue.fixed_version)
1462 unless issue.project.shared_versions.include?(issue.fixed_version)
1458 issue.init_journal(User.current)
1463 issue.init_journal(User.current)
1459 issue.fixed_version = nil
1464 issue.fixed_version = nil
1460 issue.save
1465 issue.save
1461 end
1466 end
1462 end
1467 end
1463 end
1468 end
1464
1469
1465 # Callback on file attachment
1470 # Callback on file attachment
1466 def attachment_added(attachment)
1471 def attachment_added(attachment)
1467 if current_journal && !attachment.new_record?
1472 if current_journal && !attachment.new_record?
1468 current_journal.journalize_attachment(attachment, :added)
1473 current_journal.journalize_attachment(attachment, :added)
1469 end
1474 end
1470 end
1475 end
1471
1476
1472 # Callback on attachment deletion
1477 # Callback on attachment deletion
1473 def attachment_removed(attachment)
1478 def attachment_removed(attachment)
1474 if current_journal && !attachment.new_record?
1479 if current_journal && !attachment.new_record?
1475 current_journal.journalize_attachment(attachment, :removed)
1480 current_journal.journalize_attachment(attachment, :removed)
1476 current_journal.save
1481 current_journal.save
1477 end
1482 end
1478 end
1483 end
1479
1484
1480 # Called after a relation is added
1485 # Called after a relation is added
1481 def relation_added(relation)
1486 def relation_added(relation)
1482 if current_journal
1487 if current_journal
1483 current_journal.journalize_relation(relation, :added)
1488 current_journal.journalize_relation(relation, :added)
1484 current_journal.save
1489 current_journal.save
1485 end
1490 end
1486 end
1491 end
1487
1492
1488 # Called after a relation is removed
1493 # Called after a relation is removed
1489 def relation_removed(relation)
1494 def relation_removed(relation)
1490 if current_journal
1495 if current_journal
1491 current_journal.journalize_relation(relation, :removed)
1496 current_journal.journalize_relation(relation, :removed)
1492 current_journal.save
1497 current_journal.save
1493 end
1498 end
1494 end
1499 end
1495
1500
1496 # Default assignment based on category
1501 # Default assignment based on category
1497 def default_assign
1502 def default_assign
1498 if assigned_to.nil? && category && category.assigned_to
1503 if assigned_to.nil? && category && category.assigned_to
1499 self.assigned_to = category.assigned_to
1504 self.assigned_to = category.assigned_to
1500 end
1505 end
1501 end
1506 end
1502
1507
1503 # Updates start/due dates of following issues
1508 # Updates start/due dates of following issues
1504 def reschedule_following_issues
1509 def reschedule_following_issues
1505 if start_date_changed? || due_date_changed?
1510 if start_date_changed? || due_date_changed?
1506 relations_from.each do |relation|
1511 relations_from.each do |relation|
1507 relation.set_issue_to_dates
1512 relation.set_issue_to_dates
1508 end
1513 end
1509 end
1514 end
1510 end
1515 end
1511
1516
1512 # Closes duplicates if the issue is being closed
1517 # Closes duplicates if the issue is being closed
1513 def close_duplicates
1518 def close_duplicates
1514 if closing?
1519 if closing?
1515 duplicates.each do |duplicate|
1520 duplicates.each do |duplicate|
1516 # Reload is needed in case the duplicate was updated by a previous duplicate
1521 # Reload is needed in case the duplicate was updated by a previous duplicate
1517 duplicate.reload
1522 duplicate.reload
1518 # Don't re-close it if it's already closed
1523 # Don't re-close it if it's already closed
1519 next if duplicate.closed?
1524 next if duplicate.closed?
1520 # Same user and notes
1525 # Same user and notes
1521 if @current_journal
1526 if @current_journal
1522 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1527 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1523 end
1528 end
1524 duplicate.update_attribute :status, self.status
1529 duplicate.update_attribute :status, self.status
1525 end
1530 end
1526 end
1531 end
1527 end
1532 end
1528
1533
1529 # Make sure updated_on is updated when adding a note and set updated_on now
1534 # Make sure updated_on is updated when adding a note and set updated_on now
1530 # so we can set closed_on with the same value on closing
1535 # so we can set closed_on with the same value on closing
1531 def force_updated_on_change
1536 def force_updated_on_change
1532 if @current_journal || changed?
1537 if @current_journal || changed?
1533 self.updated_on = current_time_from_proper_timezone
1538 self.updated_on = current_time_from_proper_timezone
1534 if new_record?
1539 if new_record?
1535 self.created_on = updated_on
1540 self.created_on = updated_on
1536 end
1541 end
1537 end
1542 end
1538 end
1543 end
1539
1544
1540 # Callback for setting closed_on when the issue is closed.
1545 # Callback for setting closed_on when the issue is closed.
1541 # The closed_on attribute stores the time of the last closing
1546 # The closed_on attribute stores the time of the last closing
1542 # and is preserved when the issue is reopened.
1547 # and is preserved when the issue is reopened.
1543 def update_closed_on
1548 def update_closed_on
1544 if closing?
1549 if closing?
1545 self.closed_on = updated_on
1550 self.closed_on = updated_on
1546 end
1551 end
1547 end
1552 end
1548
1553
1549 # Saves the changes in a Journal
1554 # Saves the changes in a Journal
1550 # Called after_save
1555 # Called after_save
1551 def create_journal
1556 def create_journal
1552 if current_journal
1557 if current_journal
1553 current_journal.save
1558 current_journal.save
1554 end
1559 end
1555 end
1560 end
1556
1561
1557 def send_notification
1562 def send_notification
1558 if Setting.notified_events.include?('issue_added')
1563 if Setting.notified_events.include?('issue_added')
1559 Mailer.deliver_issue_add(self)
1564 Mailer.deliver_issue_add(self)
1560 end
1565 end
1561 end
1566 end
1562
1567
1563 # Stores the previous assignee so we can still have access
1568 # Stores the previous assignee so we can still have access
1564 # to it during after_save callbacks (assigned_to_id_was is reset)
1569 # to it during after_save callbacks (assigned_to_id_was is reset)
1565 def set_assigned_to_was
1570 def set_assigned_to_was
1566 @previous_assigned_to_id = assigned_to_id_was
1571 @previous_assigned_to_id = assigned_to_id_was
1567 end
1572 end
1568
1573
1569 # Clears the previous assignee at the end of after_save callbacks
1574 # Clears the previous assignee at the end of after_save callbacks
1570 def clear_assigned_to_was
1575 def clear_assigned_to_was
1571 @assigned_to_was = nil
1576 @assigned_to_was = nil
1572 @previous_assigned_to_id = nil
1577 @previous_assigned_to_id = nil
1573 end
1578 end
1574 end
1579 end
@@ -1,670 +1,699
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
2 # Copyright (C) 2006-2015 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../../test_helper', __FILE__)
18 require File.expand_path('../../../test_helper', __FILE__)
19
19
20 class Redmine::ApiTest::IssuesTest < Redmine::ApiTest::Base
20 class Redmine::ApiTest::IssuesTest < Redmine::ApiTest::Base
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 :issue_relations,
28 :issue_relations,
29 :versions,
29 :versions,
30 :trackers,
30 :trackers,
31 :projects_trackers,
31 :projects_trackers,
32 :issue_categories,
32 :issue_categories,
33 :enabled_modules,
33 :enabled_modules,
34 :enumerations,
34 :enumerations,
35 :attachments,
35 :attachments,
36 :workflows,
36 :workflows,
37 :custom_fields,
37 :custom_fields,
38 :custom_values,
38 :custom_values,
39 :custom_fields_projects,
39 :custom_fields_projects,
40 :custom_fields_trackers,
40 :custom_fields_trackers,
41 :time_entries,
41 :time_entries,
42 :journals,
42 :journals,
43 :journal_details,
43 :journal_details,
44 :queries,
44 :queries,
45 :attachments
45 :attachments
46
46
47 test "GET /issues.xml should contain metadata" do
47 test "GET /issues.xml should contain metadata" do
48 get '/issues.xml'
48 get '/issues.xml'
49 assert_select 'issues[type=array][total_count=?][limit="25"][offset="0"]',
49 assert_select 'issues[type=array][total_count=?][limit="25"][offset="0"]',
50 assigns(:issue_count).to_s
50 assigns(:issue_count).to_s
51 end
51 end
52
52
53 test "GET /issues.xml with nometa param should not contain metadata" do
53 test "GET /issues.xml with nometa param should not contain metadata" do
54 get '/issues.xml?nometa=1'
54 get '/issues.xml?nometa=1'
55 assert_select 'issues[type=array]:not([total_count]):not([limit]):not([offset])'
55 assert_select 'issues[type=array]:not([total_count]):not([limit]):not([offset])'
56 end
56 end
57
57
58 test "GET /issues.xml with nometa header should not contain metadata" do
58 test "GET /issues.xml with nometa header should not contain metadata" do
59 get '/issues.xml', {}, {'X-Redmine-Nometa' => '1'}
59 get '/issues.xml', {}, {'X-Redmine-Nometa' => '1'}
60 assert_select 'issues[type=array]:not([total_count]):not([limit]):not([offset])'
60 assert_select 'issues[type=array]:not([total_count]):not([limit]):not([offset])'
61 end
61 end
62
62
63 test "GET /issues.xml with offset and limit" do
63 test "GET /issues.xml with offset and limit" do
64 get '/issues.xml?offset=2&limit=3'
64 get '/issues.xml?offset=2&limit=3'
65
65
66 assert_equal 3, assigns(:limit)
66 assert_equal 3, assigns(:limit)
67 assert_equal 2, assigns(:offset)
67 assert_equal 2, assigns(:offset)
68 assert_select 'issues issue', 3
68 assert_select 'issues issue', 3
69 end
69 end
70
70
71 test "GET /issues.xml with relations" do
71 test "GET /issues.xml with relations" do
72 get '/issues.xml?include=relations'
72 get '/issues.xml?include=relations'
73
73
74 assert_response :success
74 assert_response :success
75 assert_equal 'application/xml', @response.content_type
75 assert_equal 'application/xml', @response.content_type
76
76
77 assert_select 'issue id', :text => '3' do
77 assert_select 'issue id', :text => '3' do
78 assert_select '~ relations relation', 1
78 assert_select '~ relations relation', 1
79 assert_select '~ relations relation[id="2"][issue_id="2"][issue_to_id="3"][relation_type=relates]'
79 assert_select '~ relations relation[id="2"][issue_id="2"][issue_to_id="3"][relation_type=relates]'
80 end
80 end
81
81
82 assert_select 'issue id', :text => '1' do
82 assert_select 'issue id', :text => '1' do
83 assert_select '~ relations'
83 assert_select '~ relations'
84 assert_select '~ relations relation', 0
84 assert_select '~ relations relation', 0
85 end
85 end
86 end
86 end
87
87
88 test "GET /issues.xml with invalid query params" do
88 test "GET /issues.xml with invalid query params" do
89 get '/issues.xml', {:f => ['start_date'], :op => {:start_date => '='}}
89 get '/issues.xml', {:f => ['start_date'], :op => {:start_date => '='}}
90
90
91 assert_response :unprocessable_entity
91 assert_response :unprocessable_entity
92 assert_equal 'application/xml', @response.content_type
92 assert_equal 'application/xml', @response.content_type
93 assert_select 'errors error', :text => "Start date cannot be blank"
93 assert_select 'errors error', :text => "Start date cannot be blank"
94 end
94 end
95
95
96 test "GET /issues.xml with custom field filter" do
96 test "GET /issues.xml with custom field filter" do
97 get '/issues.xml',
97 get '/issues.xml',
98 {:set_filter => 1, :f => ['cf_1'], :op => {:cf_1 => '='}, :v => {:cf_1 => ['MySQL']}}
98 {:set_filter => 1, :f => ['cf_1'], :op => {:cf_1 => '='}, :v => {:cf_1 => ['MySQL']}}
99
99
100 expected_ids = Issue.visible.
100 expected_ids = Issue.visible.
101 joins(:custom_values).
101 joins(:custom_values).
102 where(:custom_values => {:custom_field_id => 1, :value => 'MySQL'}).map(&:id)
102 where(:custom_values => {:custom_field_id => 1, :value => 'MySQL'}).map(&:id)
103 assert expected_ids.any?
103 assert expected_ids.any?
104
104
105 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
105 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
106 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
106 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
107 end
107 end
108 end
108 end
109
109
110 test "GET /issues.xml with custom field filter (shorthand method)" do
110 test "GET /issues.xml with custom field filter (shorthand method)" do
111 get '/issues.xml', {:cf_1 => 'MySQL'}
111 get '/issues.xml', {:cf_1 => 'MySQL'}
112
112
113 expected_ids = Issue.visible.
113 expected_ids = Issue.visible.
114 joins(:custom_values).
114 joins(:custom_values).
115 where(:custom_values => {:custom_field_id => 1, :value => 'MySQL'}).map(&:id)
115 where(:custom_values => {:custom_field_id => 1, :value => 'MySQL'}).map(&:id)
116 assert expected_ids.any?
116 assert expected_ids.any?
117
117
118 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
118 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
119 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
119 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
120 end
120 end
121 end
121 end
122
122
123 def test_index_should_include_issue_attributes
123 def test_index_should_include_issue_attributes
124 get '/issues.xml'
124 get '/issues.xml'
125 assert_select 'issues>issue>is_private', :text => 'false'
125 assert_select 'issues>issue>is_private', :text => 'false'
126 end
126 end
127
127
128 def test_index_should_allow_timestamp_filtering
128 def test_index_should_allow_timestamp_filtering
129 Issue.delete_all
129 Issue.delete_all
130 Issue.generate!(:subject => '1').update_column(:updated_on, Time.parse("2014-01-02T10:25:00Z"))
130 Issue.generate!(:subject => '1').update_column(:updated_on, Time.parse("2014-01-02T10:25:00Z"))
131 Issue.generate!(:subject => '2').update_column(:updated_on, Time.parse("2014-01-02T12:13:00Z"))
131 Issue.generate!(:subject => '2').update_column(:updated_on, Time.parse("2014-01-02T12:13:00Z"))
132
132
133 get '/issues.xml',
133 get '/issues.xml',
134 {:set_filter => 1, :f => ['updated_on'], :op => {:updated_on => '<='},
134 {:set_filter => 1, :f => ['updated_on'], :op => {:updated_on => '<='},
135 :v => {:updated_on => ['2014-01-02T12:00:00Z']}}
135 :v => {:updated_on => ['2014-01-02T12:00:00Z']}}
136 assert_select 'issues>issue', :count => 1
136 assert_select 'issues>issue', :count => 1
137 assert_select 'issues>issue>subject', :text => '1'
137 assert_select 'issues>issue>subject', :text => '1'
138
138
139 get '/issues.xml',
139 get '/issues.xml',
140 {:set_filter => 1, :f => ['updated_on'], :op => {:updated_on => '>='},
140 {:set_filter => 1, :f => ['updated_on'], :op => {:updated_on => '>='},
141 :v => {:updated_on => ['2014-01-02T12:00:00Z']}}
141 :v => {:updated_on => ['2014-01-02T12:00:00Z']}}
142 assert_select 'issues>issue', :count => 1
142 assert_select 'issues>issue', :count => 1
143 assert_select 'issues>issue>subject', :text => '2'
143 assert_select 'issues>issue>subject', :text => '2'
144
144
145 get '/issues.xml',
145 get '/issues.xml',
146 {:set_filter => 1, :f => ['updated_on'], :op => {:updated_on => '>='},
146 {:set_filter => 1, :f => ['updated_on'], :op => {:updated_on => '>='},
147 :v => {:updated_on => ['2014-01-02T08:00:00Z']}}
147 :v => {:updated_on => ['2014-01-02T08:00:00Z']}}
148 assert_select 'issues>issue', :count => 2
148 assert_select 'issues>issue', :count => 2
149 end
149 end
150
150
151 test "GET /issues.xml with filter" do
151 test "GET /issues.xml with filter" do
152 get '/issues.xml?status_id=5'
152 get '/issues.xml?status_id=5'
153
153
154 expected_ids = Issue.visible.where(:status_id => 5).map(&:id)
154 expected_ids = Issue.visible.where(:status_id => 5).map(&:id)
155 assert expected_ids.any?
155 assert expected_ids.any?
156
156
157 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
157 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
158 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
158 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
159 end
159 end
160 end
160 end
161
161
162 test "GET /issues.json with filter" do
162 test "GET /issues.json with filter" do
163 get '/issues.json?status_id=5'
163 get '/issues.json?status_id=5'
164
164
165 json = ActiveSupport::JSON.decode(response.body)
165 json = ActiveSupport::JSON.decode(response.body)
166 status_ids_used = json['issues'].collect {|j| j['status']['id'] }
166 status_ids_used = json['issues'].collect {|j| j['status']['id'] }
167 assert_equal 3, status_ids_used.length
167 assert_equal 3, status_ids_used.length
168 assert status_ids_used.all? {|id| id == 5 }
168 assert status_ids_used.all? {|id| id == 5 }
169 end
169 end
170
170
171 test "GET /issues/:id.xml with journals" do
171 test "GET /issues/:id.xml with journals" do
172 get '/issues/1.xml?include=journals'
172 get '/issues/1.xml?include=journals'
173
173
174 assert_select 'issue journals[type=array]' do
174 assert_select 'issue journals[type=array]' do
175 assert_select 'journal[id="1"]' do
175 assert_select 'journal[id="1"]' do
176 assert_select 'details[type=array]' do
176 assert_select 'details[type=array]' do
177 assert_select 'detail[name=status_id]' do
177 assert_select 'detail[name=status_id]' do
178 assert_select 'old_value', :text => '1'
178 assert_select 'old_value', :text => '1'
179 assert_select 'new_value', :text => '2'
179 assert_select 'new_value', :text => '2'
180 end
180 end
181 end
181 end
182 end
182 end
183 end
183 end
184 end
184 end
185
185
186 test "GET /issues/:id.xml with journals should format timestamps in ISO 8601" do
186 test "GET /issues/:id.xml with journals should format timestamps in ISO 8601" do
187 get '/issues/1.xml?include=journals'
187 get '/issues/1.xml?include=journals'
188
188
189 iso_date = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/
189 iso_date = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/
190 assert_select 'issue>created_on', :text => iso_date
190 assert_select 'issue>created_on', :text => iso_date
191 assert_select 'issue>updated_on', :text => iso_date
191 assert_select 'issue>updated_on', :text => iso_date
192 assert_select 'issue journal>created_on', :text => iso_date
192 assert_select 'issue journal>created_on', :text => iso_date
193 end
193 end
194
194
195 test "GET /issues/:id.xml with custom fields" do
195 test "GET /issues/:id.xml with custom fields" do
196 get '/issues/3.xml'
196 get '/issues/3.xml'
197
197
198 assert_select 'issue custom_fields[type=array]' do
198 assert_select 'issue custom_fields[type=array]' do
199 assert_select 'custom_field[id="1"]' do
199 assert_select 'custom_field[id="1"]' do
200 assert_select 'value', :text => 'MySQL'
200 assert_select 'value', :text => 'MySQL'
201 end
201 end
202 end
202 end
203 assert_nothing_raised do
203 assert_nothing_raised do
204 Hash.from_xml(response.body).to_xml
204 Hash.from_xml(response.body).to_xml
205 end
205 end
206 end
206 end
207
207
208 test "GET /issues/:id.xml with multi custom fields" do
208 test "GET /issues/:id.xml with multi custom fields" do
209 field = CustomField.find(1)
209 field = CustomField.find(1)
210 field.update_attribute :multiple, true
210 field.update_attribute :multiple, true
211 issue = Issue.find(3)
211 issue = Issue.find(3)
212 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
212 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
213 issue.save!
213 issue.save!
214
214
215 get '/issues/3.xml'
215 get '/issues/3.xml'
216 assert_response :success
216 assert_response :success
217
217
218 assert_select 'issue custom_fields[type=array]' do
218 assert_select 'issue custom_fields[type=array]' do
219 assert_select 'custom_field[id="1"]' do
219 assert_select 'custom_field[id="1"]' do
220 assert_select 'value[type=array] value', 2
220 assert_select 'value[type=array] value', 2
221 end
221 end
222 end
222 end
223 xml = Hash.from_xml(response.body)
223 xml = Hash.from_xml(response.body)
224 custom_fields = xml['issue']['custom_fields']
224 custom_fields = xml['issue']['custom_fields']
225 assert_kind_of Array, custom_fields
225 assert_kind_of Array, custom_fields
226 field = custom_fields.detect {|f| f['id'] == '1'}
226 field = custom_fields.detect {|f| f['id'] == '1'}
227 assert_kind_of Hash, field
227 assert_kind_of Hash, field
228 assert_equal ['MySQL', 'Oracle'], field['value'].sort
228 assert_equal ['MySQL', 'Oracle'], field['value'].sort
229 end
229 end
230
230
231 test "GET /issues/:id.json with multi custom fields" do
231 test "GET /issues/:id.json with multi custom fields" do
232 field = CustomField.find(1)
232 field = CustomField.find(1)
233 field.update_attribute :multiple, true
233 field.update_attribute :multiple, true
234 issue = Issue.find(3)
234 issue = Issue.find(3)
235 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
235 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
236 issue.save!
236 issue.save!
237
237
238 get '/issues/3.json'
238 get '/issues/3.json'
239 assert_response :success
239 assert_response :success
240
240
241 json = ActiveSupport::JSON.decode(response.body)
241 json = ActiveSupport::JSON.decode(response.body)
242 custom_fields = json['issue']['custom_fields']
242 custom_fields = json['issue']['custom_fields']
243 assert_kind_of Array, custom_fields
243 assert_kind_of Array, custom_fields
244 field = custom_fields.detect {|f| f['id'] == 1}
244 field = custom_fields.detect {|f| f['id'] == 1}
245 assert_kind_of Hash, field
245 assert_kind_of Hash, field
246 assert_equal ['MySQL', 'Oracle'], field['value'].sort
246 assert_equal ['MySQL', 'Oracle'], field['value'].sort
247 end
247 end
248
248
249 test "GET /issues/:id.xml with empty value for multi custom field" do
249 test "GET /issues/:id.xml with empty value for multi custom field" do
250 field = CustomField.find(1)
250 field = CustomField.find(1)
251 field.update_attribute :multiple, true
251 field.update_attribute :multiple, true
252 issue = Issue.find(3)
252 issue = Issue.find(3)
253 issue.custom_field_values = {1 => ['']}
253 issue.custom_field_values = {1 => ['']}
254 issue.save!
254 issue.save!
255
255
256 get '/issues/3.xml'
256 get '/issues/3.xml'
257
257
258 assert_select 'issue custom_fields[type=array]' do
258 assert_select 'issue custom_fields[type=array]' do
259 assert_select 'custom_field[id="1"]' do
259 assert_select 'custom_field[id="1"]' do
260 assert_select 'value[type=array]:empty'
260 assert_select 'value[type=array]:empty'
261 end
261 end
262 end
262 end
263 xml = Hash.from_xml(response.body)
263 xml = Hash.from_xml(response.body)
264 custom_fields = xml['issue']['custom_fields']
264 custom_fields = xml['issue']['custom_fields']
265 assert_kind_of Array, custom_fields
265 assert_kind_of Array, custom_fields
266 field = custom_fields.detect {|f| f['id'] == '1'}
266 field = custom_fields.detect {|f| f['id'] == '1'}
267 assert_kind_of Hash, field
267 assert_kind_of Hash, field
268 assert_equal [], field['value']
268 assert_equal [], field['value']
269 end
269 end
270
270
271 test "GET /issues/:id.json with empty value for multi custom field" do
271 test "GET /issues/:id.json with empty value for multi custom field" do
272 field = CustomField.find(1)
272 field = CustomField.find(1)
273 field.update_attribute :multiple, true
273 field.update_attribute :multiple, true
274 issue = Issue.find(3)
274 issue = Issue.find(3)
275 issue.custom_field_values = {1 => ['']}
275 issue.custom_field_values = {1 => ['']}
276 issue.save!
276 issue.save!
277
277
278 get '/issues/3.json'
278 get '/issues/3.json'
279 assert_response :success
279 assert_response :success
280 json = ActiveSupport::JSON.decode(response.body)
280 json = ActiveSupport::JSON.decode(response.body)
281 custom_fields = json['issue']['custom_fields']
281 custom_fields = json['issue']['custom_fields']
282 assert_kind_of Array, custom_fields
282 assert_kind_of Array, custom_fields
283 field = custom_fields.detect {|f| f['id'] == 1}
283 field = custom_fields.detect {|f| f['id'] == 1}
284 assert_kind_of Hash, field
284 assert_kind_of Hash, field
285 assert_equal [], field['value'].sort
285 assert_equal [], field['value'].sort
286 end
286 end
287
287
288 test "GET /issues/:id.xml with attachments" do
288 test "GET /issues/:id.xml with attachments" do
289 get '/issues/3.xml?include=attachments'
289 get '/issues/3.xml?include=attachments'
290
290
291 assert_select 'issue attachments[type=array]' do
291 assert_select 'issue attachments[type=array]' do
292 assert_select 'attachment', 4
292 assert_select 'attachment', 4
293 assert_select 'attachment id', :text => '1' do
293 assert_select 'attachment id', :text => '1' do
294 assert_select '~ filename', :text => 'error281.txt'
294 assert_select '~ filename', :text => 'error281.txt'
295 assert_select '~ content_url', :text => 'http://www.example.com/attachments/download/1/error281.txt'
295 assert_select '~ content_url', :text => 'http://www.example.com/attachments/download/1/error281.txt'
296 end
296 end
297 end
297 end
298 end
298 end
299
299
300 test "GET /issues/:id.xml with subtasks" do
300 test "GET /issues/:id.xml with subtasks" do
301 issue = Issue.generate_with_descendants!(:project_id => 1)
301 issue = Issue.generate_with_descendants!(:project_id => 1)
302 get "/issues/#{issue.id}.xml?include=children"
302 get "/issues/#{issue.id}.xml?include=children"
303
303
304 assert_select 'issue id', :text => issue.id.to_s do
304 assert_select 'issue id', :text => issue.id.to_s do
305 assert_select '~ children[type=array] > issue', 2
305 assert_select '~ children[type=array] > issue', 2
306 assert_select '~ children[type=array] > issue > children', 1
306 assert_select '~ children[type=array] > issue > children', 1
307 end
307 end
308 end
308 end
309
309
310 test "GET /issues/:id.json with subtasks" do
310 test "GET /issues/:id.json with subtasks" do
311 issue = Issue.generate_with_descendants!(:project_id => 1)
311 issue = Issue.generate_with_descendants!(:project_id => 1)
312 get "/issues/#{issue.id}.json?include=children"
312 get "/issues/#{issue.id}.json?include=children"
313
313
314 json = ActiveSupport::JSON.decode(response.body)
314 json = ActiveSupport::JSON.decode(response.body)
315 assert_equal 2, json['issue']['children'].size
315 assert_equal 2, json['issue']['children'].size
316 assert_equal 1, json['issue']['children'].select {|child| child.key?('children')}.size
316 assert_equal 1, json['issue']['children'].select {|child| child.key?('children')}.size
317 end
317 end
318
318
319 def test_show_should_include_issue_attributes
319 def test_show_should_include_issue_attributes
320 get '/issues/1.xml'
320 get '/issues/1.xml'
321 assert_select 'issue>is_private', :text => 'false'
321 assert_select 'issue>is_private', :text => 'false'
322 end
322 end
323
323
324 test "GET /issues/:id.xml?include=watchers should include watchers" do
324 test "GET /issues/:id.xml?include=watchers should include watchers" do
325 Watcher.create!(:user_id => 3, :watchable => Issue.find(1))
325 Watcher.create!(:user_id => 3, :watchable => Issue.find(1))
326
326
327 get '/issues/1.xml?include=watchers', {}, credentials('jsmith')
327 get '/issues/1.xml?include=watchers', {}, credentials('jsmith')
328
328
329 assert_response :ok
329 assert_response :ok
330 assert_equal 'application/xml', response.content_type
330 assert_equal 'application/xml', response.content_type
331 assert_select 'issue' do
331 assert_select 'issue' do
332 assert_select 'watchers', Issue.find(1).watchers.count
332 assert_select 'watchers', Issue.find(1).watchers.count
333 assert_select 'watchers' do
333 assert_select 'watchers' do
334 assert_select 'user[id="3"]'
334 assert_select 'user[id="3"]'
335 end
335 end
336 end
336 end
337 end
337 end
338
338
339 test "POST /issues.xml should create an issue with the attributes" do
339 test "POST /issues.xml should create an issue with the attributes" do
340
340
341 payload = <<-XML
341 payload = <<-XML
342 <?xml version="1.0" encoding="UTF-8" ?>
342 <?xml version="1.0" encoding="UTF-8" ?>
343 <issue>
343 <issue>
344 <project_id>1</project_id>
344 <project_id>1</project_id>
345 <tracker_id>2</tracker_id>
345 <tracker_id>2</tracker_id>
346 <status_id>3</status_id>
346 <status_id>3</status_id>
347 <subject>API test</subject>
347 <subject>API test</subject>
348 </issue>
348 </issue>
349 XML
349 XML
350
350
351 assert_difference('Issue.count') do
351 assert_difference('Issue.count') do
352 post '/issues.xml', payload, {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith'))
352 post '/issues.xml', payload, {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith'))
353 end
353 end
354 issue = Issue.order('id DESC').first
354 issue = Issue.order('id DESC').first
355 assert_equal 1, issue.project_id
355 assert_equal 1, issue.project_id
356 assert_equal 2, issue.tracker_id
356 assert_equal 2, issue.tracker_id
357 assert_equal 3, issue.status_id
357 assert_equal 3, issue.status_id
358 assert_equal 'API test', issue.subject
358 assert_equal 'API test', issue.subject
359
359
360 assert_response :created
360 assert_response :created
361 assert_equal 'application/xml', @response.content_type
361 assert_equal 'application/xml', @response.content_type
362 assert_select 'issue > id', :text => issue.id.to_s
362 assert_select 'issue > id', :text => issue.id.to_s
363 end
363 end
364
364
365 test "POST /issues.xml with watcher_user_ids should create issue with watchers" do
365 test "POST /issues.xml with watcher_user_ids should create issue with watchers" do
366 assert_difference('Issue.count') do
366 assert_difference('Issue.count') do
367 post '/issues.xml',
367 post '/issues.xml',
368 {:issue => {:project_id => 1, :subject => 'Watchers',
368 {:issue => {:project_id => 1, :subject => 'Watchers',
369 :tracker_id => 2, :status_id => 3, :watcher_user_ids => [3, 1]}}, credentials('jsmith')
369 :tracker_id => 2, :status_id => 3, :watcher_user_ids => [3, 1]}}, credentials('jsmith')
370 assert_response :created
370 assert_response :created
371 end
371 end
372 issue = Issue.order('id desc').first
372 issue = Issue.order('id desc').first
373 assert_equal 2, issue.watchers.size
373 assert_equal 2, issue.watchers.size
374 assert_equal [1, 3], issue.watcher_user_ids.sort
374 assert_equal [1, 3], issue.watcher_user_ids.sort
375 end
375 end
376
376
377 test "POST /issues.xml with failure should return errors" do
377 test "POST /issues.xml with failure should return errors" do
378 assert_no_difference('Issue.count') do
378 assert_no_difference('Issue.count') do
379 post '/issues.xml', {:issue => {:project_id => 1}}, credentials('jsmith')
379 post '/issues.xml', {:issue => {:project_id => 1}}, credentials('jsmith')
380 end
380 end
381
381
382 assert_select 'errors error', :text => "Subject cannot be blank"
382 assert_select 'errors error', :text => "Subject cannot be blank"
383 end
383 end
384
384
385 test "POST /issues.json should create an issue with the attributes" do
385 test "POST /issues.json should create an issue with the attributes" do
386
386
387 payload = <<-JSON
387 payload = <<-JSON
388 {
388 {
389 "issue": {
389 "issue": {
390 "project_id": "1",
390 "project_id": "1",
391 "tracker_id": "2",
391 "tracker_id": "2",
392 "status_id": "3",
392 "status_id": "3",
393 "subject": "API test"
393 "subject": "API test"
394 }
394 }
395 }
395 }
396 JSON
396 JSON
397
397
398 assert_difference('Issue.count') do
398 assert_difference('Issue.count') do
399 post '/issues.json', payload, {"CONTENT_TYPE" => 'application/json'}.merge(credentials('jsmith'))
399 post '/issues.json', payload, {"CONTENT_TYPE" => 'application/json'}.merge(credentials('jsmith'))
400 end
400 end
401
401
402 issue = Issue.order('id DESC').first
402 issue = Issue.order('id DESC').first
403 assert_equal 1, issue.project_id
403 assert_equal 1, issue.project_id
404 assert_equal 2, issue.tracker_id
404 assert_equal 2, issue.tracker_id
405 assert_equal 3, issue.status_id
405 assert_equal 3, issue.status_id
406 assert_equal 'API test', issue.subject
406 assert_equal 'API test', issue.subject
407 end
407 end
408
408
409 test "POST /issues.json without tracker_id should accept custom fields" do
410 field = IssueCustomField.generate!(
411 :field_format => 'list',
412 :multiple => true,
413 :possible_values => ["V1", "V2", "V3"],
414 :default_value => "V2",
415 :is_for_all => true,
416 :trackers => Tracker.all.to_a
417 )
418
419 payload = <<-JSON
420 {
421 "issue": {
422 "project_id": "1",
423 "subject": "Multivalued custom field",
424 "custom_field_values":{"#{field.id}":["V1","V3"]}
425 }
426 }
427 JSON
428
429 assert_difference('Issue.count') do
430 post '/issues.json', payload, {"CONTENT_TYPE" => 'application/json'}.merge(credentials('jsmith'))
431 end
432
433 assert_response :created
434 issue = Issue.order('id DESC').first
435 assert_equal ["V1", "V3"], issue.custom_field_value(field)
436 end
437
409 test "POST /issues.json with failure should return errors" do
438 test "POST /issues.json with failure should return errors" do
410 assert_no_difference('Issue.count') do
439 assert_no_difference('Issue.count') do
411 post '/issues.json', {:issue => {:project_id => 1}}, credentials('jsmith')
440 post '/issues.json', {:issue => {:project_id => 1}}, credentials('jsmith')
412 end
441 end
413
442
414 json = ActiveSupport::JSON.decode(response.body)
443 json = ActiveSupport::JSON.decode(response.body)
415 assert json['errors'].include?("Subject cannot be blank")
444 assert json['errors'].include?("Subject cannot be blank")
416 end
445 end
417
446
418 test "PUT /issues/:id.xml" do
447 test "PUT /issues/:id.xml" do
419 assert_difference('Journal.count') do
448 assert_difference('Journal.count') do
420 put '/issues/6.xml',
449 put '/issues/6.xml',
421 {:issue => {:subject => 'API update', :notes => 'A new note'}},
450 {:issue => {:subject => 'API update', :notes => 'A new note'}},
422 credentials('jsmith')
451 credentials('jsmith')
423 end
452 end
424
453
425 issue = Issue.find(6)
454 issue = Issue.find(6)
426 assert_equal "API update", issue.subject
455 assert_equal "API update", issue.subject
427 journal = Journal.last
456 journal = Journal.last
428 assert_equal "A new note", journal.notes
457 assert_equal "A new note", journal.notes
429 end
458 end
430
459
431 test "PUT /issues/:id.xml with custom fields" do
460 test "PUT /issues/:id.xml with custom fields" do
432 put '/issues/3.xml',
461 put '/issues/3.xml',
433 {:issue => {:custom_fields => [
462 {:issue => {:custom_fields => [
434 {'id' => '1', 'value' => 'PostgreSQL' },
463 {'id' => '1', 'value' => 'PostgreSQL' },
435 {'id' => '2', 'value' => '150'}
464 {'id' => '2', 'value' => '150'}
436 ]}},
465 ]}},
437 credentials('jsmith')
466 credentials('jsmith')
438
467
439 issue = Issue.find(3)
468 issue = Issue.find(3)
440 assert_equal '150', issue.custom_value_for(2).value
469 assert_equal '150', issue.custom_value_for(2).value
441 assert_equal 'PostgreSQL', issue.custom_value_for(1).value
470 assert_equal 'PostgreSQL', issue.custom_value_for(1).value
442 end
471 end
443
472
444 test "PUT /issues/:id.xml with multi custom fields" do
473 test "PUT /issues/:id.xml with multi custom fields" do
445 field = CustomField.find(1)
474 field = CustomField.find(1)
446 field.update_attribute :multiple, true
475 field.update_attribute :multiple, true
447
476
448 put '/issues/3.xml',
477 put '/issues/3.xml',
449 {:issue => {:custom_fields => [
478 {:issue => {:custom_fields => [
450 {'id' => '1', 'value' => ['MySQL', 'PostgreSQL'] },
479 {'id' => '1', 'value' => ['MySQL', 'PostgreSQL'] },
451 {'id' => '2', 'value' => '150'}
480 {'id' => '2', 'value' => '150'}
452 ]}},
481 ]}},
453 credentials('jsmith')
482 credentials('jsmith')
454
483
455 issue = Issue.find(3)
484 issue = Issue.find(3)
456 assert_equal '150', issue.custom_value_for(2).value
485 assert_equal '150', issue.custom_value_for(2).value
457 assert_equal ['MySQL', 'PostgreSQL'], issue.custom_field_value(1).sort
486 assert_equal ['MySQL', 'PostgreSQL'], issue.custom_field_value(1).sort
458 end
487 end
459
488
460 test "PUT /issues/:id.xml with project change" do
489 test "PUT /issues/:id.xml with project change" do
461 put '/issues/3.xml',
490 put '/issues/3.xml',
462 {:issue => {:project_id => 2, :subject => 'Project changed'}},
491 {:issue => {:project_id => 2, :subject => 'Project changed'}},
463 credentials('jsmith')
492 credentials('jsmith')
464
493
465 issue = Issue.find(3)
494 issue = Issue.find(3)
466 assert_equal 2, issue.project_id
495 assert_equal 2, issue.project_id
467 assert_equal 'Project changed', issue.subject
496 assert_equal 'Project changed', issue.subject
468 end
497 end
469
498
470 test "PUT /issues/:id.xml with notes only" do
499 test "PUT /issues/:id.xml with notes only" do
471 assert_difference('Journal.count') do
500 assert_difference('Journal.count') do
472 put '/issues/6.xml',
501 put '/issues/6.xml',
473 {:issue => {:notes => 'Notes only'}},
502 {:issue => {:notes => 'Notes only'}},
474 credentials('jsmith')
503 credentials('jsmith')
475 end
504 end
476
505
477 journal = Journal.last
506 journal = Journal.last
478 assert_equal "Notes only", journal.notes
507 assert_equal "Notes only", journal.notes
479 end
508 end
480
509
481 test "PUT /issues/:id.xml with failed update" do
510 test "PUT /issues/:id.xml with failed update" do
482 put '/issues/6.xml', {:issue => {:subject => ''}}, credentials('jsmith')
511 put '/issues/6.xml', {:issue => {:subject => ''}}, credentials('jsmith')
483
512
484 assert_response :unprocessable_entity
513 assert_response :unprocessable_entity
485 assert_select 'errors error', :text => "Subject cannot be blank"
514 assert_select 'errors error', :text => "Subject cannot be blank"
486 end
515 end
487
516
488 test "PUT /issues/:id.json" do
517 test "PUT /issues/:id.json" do
489 assert_difference('Journal.count') do
518 assert_difference('Journal.count') do
490 put '/issues/6.json',
519 put '/issues/6.json',
491 {:issue => {:subject => 'API update', :notes => 'A new note'}},
520 {:issue => {:subject => 'API update', :notes => 'A new note'}},
492 credentials('jsmith')
521 credentials('jsmith')
493
522
494 assert_response :ok
523 assert_response :ok
495 assert_equal '', response.body
524 assert_equal '', response.body
496 end
525 end
497
526
498 issue = Issue.find(6)
527 issue = Issue.find(6)
499 assert_equal "API update", issue.subject
528 assert_equal "API update", issue.subject
500 journal = Journal.last
529 journal = Journal.last
501 assert_equal "A new note", journal.notes
530 assert_equal "A new note", journal.notes
502 end
531 end
503
532
504 test "PUT /issues/:id.json with failed update" do
533 test "PUT /issues/:id.json with failed update" do
505 put '/issues/6.json', {:issue => {:subject => ''}}, credentials('jsmith')
534 put '/issues/6.json', {:issue => {:subject => ''}}, credentials('jsmith')
506
535
507 assert_response :unprocessable_entity
536 assert_response :unprocessable_entity
508 json = ActiveSupport::JSON.decode(response.body)
537 json = ActiveSupport::JSON.decode(response.body)
509 assert json['errors'].include?("Subject cannot be blank")
538 assert json['errors'].include?("Subject cannot be blank")
510 end
539 end
511
540
512 test "DELETE /issues/:id.xml" do
541 test "DELETE /issues/:id.xml" do
513 assert_difference('Issue.count', -1) do
542 assert_difference('Issue.count', -1) do
514 delete '/issues/6.xml', {}, credentials('jsmith')
543 delete '/issues/6.xml', {}, credentials('jsmith')
515
544
516 assert_response :ok
545 assert_response :ok
517 assert_equal '', response.body
546 assert_equal '', response.body
518 end
547 end
519 assert_nil Issue.find_by_id(6)
548 assert_nil Issue.find_by_id(6)
520 end
549 end
521
550
522 test "DELETE /issues/:id.json" do
551 test "DELETE /issues/:id.json" do
523 assert_difference('Issue.count', -1) do
552 assert_difference('Issue.count', -1) do
524 delete '/issues/6.json', {}, credentials('jsmith')
553 delete '/issues/6.json', {}, credentials('jsmith')
525
554
526 assert_response :ok
555 assert_response :ok
527 assert_equal '', response.body
556 assert_equal '', response.body
528 end
557 end
529 assert_nil Issue.find_by_id(6)
558 assert_nil Issue.find_by_id(6)
530 end
559 end
531
560
532 test "POST /issues/:id/watchers.xml should add watcher" do
561 test "POST /issues/:id/watchers.xml should add watcher" do
533 assert_difference 'Watcher.count' do
562 assert_difference 'Watcher.count' do
534 post '/issues/1/watchers.xml', {:user_id => 3}, credentials('jsmith')
563 post '/issues/1/watchers.xml', {:user_id => 3}, credentials('jsmith')
535
564
536 assert_response :ok
565 assert_response :ok
537 assert_equal '', response.body
566 assert_equal '', response.body
538 end
567 end
539 watcher = Watcher.order('id desc').first
568 watcher = Watcher.order('id desc').first
540 assert_equal Issue.find(1), watcher.watchable
569 assert_equal Issue.find(1), watcher.watchable
541 assert_equal User.find(3), watcher.user
570 assert_equal User.find(3), watcher.user
542 end
571 end
543
572
544 test "DELETE /issues/:id/watchers/:user_id.xml should remove watcher" do
573 test "DELETE /issues/:id/watchers/:user_id.xml should remove watcher" do
545 Watcher.create!(:user_id => 3, :watchable => Issue.find(1))
574 Watcher.create!(:user_id => 3, :watchable => Issue.find(1))
546
575
547 assert_difference 'Watcher.count', -1 do
576 assert_difference 'Watcher.count', -1 do
548 delete '/issues/1/watchers/3.xml', {}, credentials('jsmith')
577 delete '/issues/1/watchers/3.xml', {}, credentials('jsmith')
549
578
550 assert_response :ok
579 assert_response :ok
551 assert_equal '', response.body
580 assert_equal '', response.body
552 end
581 end
553 assert_equal false, Issue.find(1).watched_by?(User.find(3))
582 assert_equal false, Issue.find(1).watched_by?(User.find(3))
554 end
583 end
555
584
556 def test_create_issue_with_uploaded_file
585 def test_create_issue_with_uploaded_file
557 token = xml_upload('test_create_with_upload', credentials('jsmith'))
586 token = xml_upload('test_create_with_upload', credentials('jsmith'))
558 attachment = Attachment.find_by_token(token)
587 attachment = Attachment.find_by_token(token)
559
588
560 # create the issue with the upload's token
589 # create the issue with the upload's token
561 assert_difference 'Issue.count' do
590 assert_difference 'Issue.count' do
562 post '/issues.xml',
591 post '/issues.xml',
563 {:issue => {:project_id => 1, :subject => 'Uploaded file',
592 {:issue => {:project_id => 1, :subject => 'Uploaded file',
564 :uploads => [{:token => token, :filename => 'test.txt',
593 :uploads => [{:token => token, :filename => 'test.txt',
565 :content_type => 'text/plain'}]}},
594 :content_type => 'text/plain'}]}},
566 credentials('jsmith')
595 credentials('jsmith')
567 assert_response :created
596 assert_response :created
568 end
597 end
569 issue = Issue.order('id DESC').first
598 issue = Issue.order('id DESC').first
570 assert_equal 1, issue.attachments.count
599 assert_equal 1, issue.attachments.count
571 assert_equal attachment, issue.attachments.first
600 assert_equal attachment, issue.attachments.first
572
601
573 attachment.reload
602 attachment.reload
574 assert_equal 'test.txt', attachment.filename
603 assert_equal 'test.txt', attachment.filename
575 assert_equal 'text/plain', attachment.content_type
604 assert_equal 'text/plain', attachment.content_type
576 assert_equal 'test_create_with_upload'.size, attachment.filesize
605 assert_equal 'test_create_with_upload'.size, attachment.filesize
577 assert_equal 2, attachment.author_id
606 assert_equal 2, attachment.author_id
578
607
579 # get the issue with its attachments
608 # get the issue with its attachments
580 get "/issues/#{issue.id}.xml", :include => 'attachments'
609 get "/issues/#{issue.id}.xml", :include => 'attachments'
581 assert_response :success
610 assert_response :success
582 xml = Hash.from_xml(response.body)
611 xml = Hash.from_xml(response.body)
583 attachments = xml['issue']['attachments']
612 attachments = xml['issue']['attachments']
584 assert_kind_of Array, attachments
613 assert_kind_of Array, attachments
585 assert_equal 1, attachments.size
614 assert_equal 1, attachments.size
586 url = attachments.first['content_url']
615 url = attachments.first['content_url']
587 assert_not_nil url
616 assert_not_nil url
588
617
589 # download the attachment
618 # download the attachment
590 get url
619 get url
591 assert_response :success
620 assert_response :success
592 assert_equal 'test_create_with_upload', response.body
621 assert_equal 'test_create_with_upload', response.body
593 end
622 end
594
623
595 def test_create_issue_with_multiple_uploaded_files_as_xml
624 def test_create_issue_with_multiple_uploaded_files_as_xml
596 token1 = xml_upload('File content 1', credentials('jsmith'))
625 token1 = xml_upload('File content 1', credentials('jsmith'))
597 token2 = xml_upload('File content 2', credentials('jsmith'))
626 token2 = xml_upload('File content 2', credentials('jsmith'))
598
627
599 payload = <<-XML
628 payload = <<-XML
600 <?xml version="1.0" encoding="UTF-8" ?>
629 <?xml version="1.0" encoding="UTF-8" ?>
601 <issue>
630 <issue>
602 <project_id>1</project_id>
631 <project_id>1</project_id>
603 <tracker_id>1</tracker_id>
632 <tracker_id>1</tracker_id>
604 <subject>Issue with multiple attachments</subject>
633 <subject>Issue with multiple attachments</subject>
605 <uploads type="array">
634 <uploads type="array">
606 <upload>
635 <upload>
607 <token>#{token1}</token>
636 <token>#{token1}</token>
608 <filename>test1.txt</filename>
637 <filename>test1.txt</filename>
609 </upload>
638 </upload>
610 <upload>
639 <upload>
611 <token>#{token2}</token>
640 <token>#{token2}</token>
612 <filename>test1.txt</filename>
641 <filename>test1.txt</filename>
613 </upload>
642 </upload>
614 </uploads>
643 </uploads>
615 </issue>
644 </issue>
616 XML
645 XML
617
646
618 assert_difference 'Issue.count' do
647 assert_difference 'Issue.count' do
619 post '/issues.xml', payload, {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith'))
648 post '/issues.xml', payload, {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith'))
620 assert_response :created
649 assert_response :created
621 end
650 end
622 issue = Issue.order('id DESC').first
651 issue = Issue.order('id DESC').first
623 assert_equal 2, issue.attachments.count
652 assert_equal 2, issue.attachments.count
624 end
653 end
625
654
626 def test_create_issue_with_multiple_uploaded_files_as_json
655 def test_create_issue_with_multiple_uploaded_files_as_json
627 token1 = json_upload('File content 1', credentials('jsmith'))
656 token1 = json_upload('File content 1', credentials('jsmith'))
628 token2 = json_upload('File content 2', credentials('jsmith'))
657 token2 = json_upload('File content 2', credentials('jsmith'))
629
658
630 payload = <<-JSON
659 payload = <<-JSON
631 {
660 {
632 "issue": {
661 "issue": {
633 "project_id": "1",
662 "project_id": "1",
634 "tracker_id": "1",
663 "tracker_id": "1",
635 "subject": "Issue with multiple attachments",
664 "subject": "Issue with multiple attachments",
636 "uploads": [
665 "uploads": [
637 {"token": "#{token1}", "filename": "test1.txt"},
666 {"token": "#{token1}", "filename": "test1.txt"},
638 {"token": "#{token2}", "filename": "test2.txt"}
667 {"token": "#{token2}", "filename": "test2.txt"}
639 ]
668 ]
640 }
669 }
641 }
670 }
642 JSON
671 JSON
643
672
644 assert_difference 'Issue.count' do
673 assert_difference 'Issue.count' do
645 post '/issues.json', payload, {"CONTENT_TYPE" => 'application/json'}.merge(credentials('jsmith'))
674 post '/issues.json', payload, {"CONTENT_TYPE" => 'application/json'}.merge(credentials('jsmith'))
646 assert_response :created
675 assert_response :created
647 end
676 end
648 issue = Issue.order('id DESC').first
677 issue = Issue.order('id DESC').first
649 assert_equal 2, issue.attachments.count
678 assert_equal 2, issue.attachments.count
650 end
679 end
651
680
652 def test_update_issue_with_uploaded_file
681 def test_update_issue_with_uploaded_file
653 token = xml_upload('test_upload_with_upload', credentials('jsmith'))
682 token = xml_upload('test_upload_with_upload', credentials('jsmith'))
654 attachment = Attachment.find_by_token(token)
683 attachment = Attachment.find_by_token(token)
655
684
656 # update the issue with the upload's token
685 # update the issue with the upload's token
657 assert_difference 'Journal.count' do
686 assert_difference 'Journal.count' do
658 put '/issues/1.xml',
687 put '/issues/1.xml',
659 {:issue => {:notes => 'Attachment added',
688 {:issue => {:notes => 'Attachment added',
660 :uploads => [{:token => token, :filename => 'test.txt',
689 :uploads => [{:token => token, :filename => 'test.txt',
661 :content_type => 'text/plain'}]}},
690 :content_type => 'text/plain'}]}},
662 credentials('jsmith')
691 credentials('jsmith')
663 assert_response :ok
692 assert_response :ok
664 assert_equal '', @response.body
693 assert_equal '', @response.body
665 end
694 end
666
695
667 issue = Issue.find(1)
696 issue = Issue.find(1)
668 assert_include attachment, issue.attachments
697 assert_include attachment, issue.attachments
669 end
698 end
670 end
699 end
General Comments 0
You need to be logged in to leave comments. Login now