##// END OF EJS Templates
Merged r14083 (#19368)....
Jean-Philippe Lang -
r13713:b956621b013b
parent child
Show More
@@ -1,1563 +1,1568
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 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
648 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
644 # even if the user turns off the setting later
649 # even if the user turns off the setting later
645 def update_done_ratio_from_issue_status
650 def update_done_ratio_from_issue_status
646 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
651 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
647 self.done_ratio = status.default_done_ratio
652 self.done_ratio = status.default_done_ratio
648 end
653 end
649 end
654 end
650
655
651 def init_journal(user, notes = "")
656 def init_journal(user, notes = "")
652 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
657 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
653 end
658 end
654
659
655 # Returns the current journal or nil if it's not initialized
660 # Returns the current journal or nil if it's not initialized
656 def current_journal
661 def current_journal
657 @current_journal
662 @current_journal
658 end
663 end
659
664
660 # Returns the names of attributes that are journalized when updating the issue
665 # Returns the names of attributes that are journalized when updating the issue
661 def journalized_attribute_names
666 def journalized_attribute_names
662 Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
667 Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
663 end
668 end
664
669
665 # Returns the id of the last journal or nil
670 # Returns the id of the last journal or nil
666 def last_journal_id
671 def last_journal_id
667 if new_record?
672 if new_record?
668 nil
673 nil
669 else
674 else
670 journals.maximum(:id)
675 journals.maximum(:id)
671 end
676 end
672 end
677 end
673
678
674 # Returns a scope for journals that have an id greater than journal_id
679 # Returns a scope for journals that have an id greater than journal_id
675 def journals_after(journal_id)
680 def journals_after(journal_id)
676 scope = journals.reorder("#{Journal.table_name}.id ASC")
681 scope = journals.reorder("#{Journal.table_name}.id ASC")
677 if journal_id.present?
682 if journal_id.present?
678 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
683 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
679 end
684 end
680 scope
685 scope
681 end
686 end
682
687
683 # Returns the initial status of the issue
688 # Returns the initial status of the issue
684 # Returns nil for a new issue
689 # Returns nil for a new issue
685 def status_was
690 def status_was
686 if status_id_changed?
691 if status_id_changed?
687 if status_id_was.to_i > 0
692 if status_id_was.to_i > 0
688 @status_was ||= IssueStatus.find_by_id(status_id_was)
693 @status_was ||= IssueStatus.find_by_id(status_id_was)
689 end
694 end
690 else
695 else
691 @status_was ||= status
696 @status_was ||= status
692 end
697 end
693 end
698 end
694
699
695 # Return true if the issue is closed, otherwise false
700 # Return true if the issue is closed, otherwise false
696 def closed?
701 def closed?
697 status.present? && status.is_closed?
702 status.present? && status.is_closed?
698 end
703 end
699
704
700 # Returns true if the issue was closed when loaded
705 # Returns true if the issue was closed when loaded
701 def was_closed?
706 def was_closed?
702 status_was.present? && status_was.is_closed?
707 status_was.present? && status_was.is_closed?
703 end
708 end
704
709
705 # Return true if the issue is being reopened
710 # Return true if the issue is being reopened
706 def reopening?
711 def reopening?
707 if new_record?
712 if new_record?
708 false
713 false
709 else
714 else
710 status_id_changed? && !closed? && was_closed?
715 status_id_changed? && !closed? && was_closed?
711 end
716 end
712 end
717 end
713 alias :reopened? :reopening?
718 alias :reopened? :reopening?
714
719
715 # Return true if the issue is being closed
720 # Return true if the issue is being closed
716 def closing?
721 def closing?
717 if new_record?
722 if new_record?
718 closed?
723 closed?
719 else
724 else
720 status_id_changed? && closed? && !was_closed?
725 status_id_changed? && closed? && !was_closed?
721 end
726 end
722 end
727 end
723
728
724 # Returns true if the issue is overdue
729 # Returns true if the issue is overdue
725 def overdue?
730 def overdue?
726 due_date.present? && (due_date < Date.today) && !closed?
731 due_date.present? && (due_date < Date.today) && !closed?
727 end
732 end
728
733
729 # Is the amount of work done less than it should for the due date
734 # Is the amount of work done less than it should for the due date
730 def behind_schedule?
735 def behind_schedule?
731 return false if start_date.nil? || due_date.nil?
736 return false if start_date.nil? || due_date.nil?
732 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
737 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
733 return done_date <= Date.today
738 return done_date <= Date.today
734 end
739 end
735
740
736 # Does this issue have children?
741 # Does this issue have children?
737 def children?
742 def children?
738 !leaf?
743 !leaf?
739 end
744 end
740
745
741 # Users the issue can be assigned to
746 # Users the issue can be assigned to
742 def assignable_users
747 def assignable_users
743 users = project.assignable_users.to_a
748 users = project.assignable_users.to_a
744 users << author if author
749 users << author if author
745 users << assigned_to if assigned_to
750 users << assigned_to if assigned_to
746 users.uniq.sort
751 users.uniq.sort
747 end
752 end
748
753
749 # Versions that the issue can be assigned to
754 # Versions that the issue can be assigned to
750 def assignable_versions
755 def assignable_versions
751 return @assignable_versions if @assignable_versions
756 return @assignable_versions if @assignable_versions
752
757
753 versions = project.shared_versions.open.to_a
758 versions = project.shared_versions.open.to_a
754 if fixed_version
759 if fixed_version
755 if fixed_version_id_changed?
760 if fixed_version_id_changed?
756 # nothing to do
761 # nothing to do
757 elsif project_id_changed?
762 elsif project_id_changed?
758 if project.shared_versions.include?(fixed_version)
763 if project.shared_versions.include?(fixed_version)
759 versions << fixed_version
764 versions << fixed_version
760 end
765 end
761 else
766 else
762 versions << fixed_version
767 versions << fixed_version
763 end
768 end
764 end
769 end
765 @assignable_versions = versions.uniq.sort
770 @assignable_versions = versions.uniq.sort
766 end
771 end
767
772
768 # Returns true if this issue is blocked by another issue that is still open
773 # Returns true if this issue is blocked by another issue that is still open
769 def blocked?
774 def blocked?
770 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
775 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
771 end
776 end
772
777
773 # Returns the default status of the issue based on its tracker
778 # Returns the default status of the issue based on its tracker
774 # Returns nil if tracker is nil
779 # Returns nil if tracker is nil
775 def default_status
780 def default_status
776 tracker.try(:default_status)
781 tracker.try(:default_status)
777 end
782 end
778
783
779 # Returns an array of statuses that user is able to apply
784 # Returns an array of statuses that user is able to apply
780 def new_statuses_allowed_to(user=User.current, include_default=false)
785 def new_statuses_allowed_to(user=User.current, include_default=false)
781 if new_record? && @copied_from
786 if new_record? && @copied_from
782 [default_status, @copied_from.status].compact.uniq.sort
787 [default_status, @copied_from.status].compact.uniq.sort
783 else
788 else
784 initial_status = nil
789 initial_status = nil
785 if new_record?
790 if new_record?
786 initial_status = default_status
791 initial_status = default_status
787 elsif tracker_id_changed?
792 elsif tracker_id_changed?
788 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
793 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
789 initial_status = default_status
794 initial_status = default_status
790 elsif tracker.issue_status_ids.include?(status_id_was)
795 elsif tracker.issue_status_ids.include?(status_id_was)
791 initial_status = IssueStatus.find_by_id(status_id_was)
796 initial_status = IssueStatus.find_by_id(status_id_was)
792 else
797 else
793 initial_status = default_status
798 initial_status = default_status
794 end
799 end
795 else
800 else
796 initial_status = status_was
801 initial_status = status_was
797 end
802 end
798
803
799 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
804 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
800 assignee_transitions_allowed = initial_assigned_to_id.present? &&
805 assignee_transitions_allowed = initial_assigned_to_id.present? &&
801 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
806 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
802
807
803 statuses = []
808 statuses = []
804 if initial_status
809 if initial_status
805 statuses += initial_status.find_new_statuses_allowed_to(
810 statuses += initial_status.find_new_statuses_allowed_to(
806 user.admin ? Role.all.to_a : user.roles_for_project(project),
811 user.admin ? Role.all.to_a : user.roles_for_project(project),
807 tracker,
812 tracker,
808 author == user,
813 author == user,
809 assignee_transitions_allowed
814 assignee_transitions_allowed
810 )
815 )
811 end
816 end
812 statuses << initial_status unless statuses.empty?
817 statuses << initial_status unless statuses.empty?
813 statuses << default_status if include_default
818 statuses << default_status if include_default
814 statuses = statuses.compact.uniq.sort
819 statuses = statuses.compact.uniq.sort
815 if blocked?
820 if blocked?
816 statuses.reject!(&:is_closed?)
821 statuses.reject!(&:is_closed?)
817 end
822 end
818 statuses
823 statuses
819 end
824 end
820 end
825 end
821
826
822 # Returns the previous assignee (user or group) if changed
827 # Returns the previous assignee (user or group) if changed
823 def assigned_to_was
828 def assigned_to_was
824 # assigned_to_id_was is reset before after_save callbacks
829 # assigned_to_id_was is reset before after_save callbacks
825 user_id = @previous_assigned_to_id || assigned_to_id_was
830 user_id = @previous_assigned_to_id || assigned_to_id_was
826 if user_id && user_id != assigned_to_id
831 if user_id && user_id != assigned_to_id
827 @assigned_to_was ||= Principal.find_by_id(user_id)
832 @assigned_to_was ||= Principal.find_by_id(user_id)
828 end
833 end
829 end
834 end
830
835
831 # Returns the users that should be notified
836 # Returns the users that should be notified
832 def notified_users
837 def notified_users
833 notified = []
838 notified = []
834 # Author and assignee are always notified unless they have been
839 # Author and assignee are always notified unless they have been
835 # locked or don't want to be notified
840 # locked or don't want to be notified
836 notified << author if author
841 notified << author if author
837 if assigned_to
842 if assigned_to
838 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
843 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
839 end
844 end
840 if assigned_to_was
845 if assigned_to_was
841 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
846 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
842 end
847 end
843 notified = notified.select {|u| u.active? && u.notify_about?(self)}
848 notified = notified.select {|u| u.active? && u.notify_about?(self)}
844
849
845 notified += project.notified_users
850 notified += project.notified_users
846 notified.uniq!
851 notified.uniq!
847 # Remove users that can not view the issue
852 # Remove users that can not view the issue
848 notified.reject! {|user| !visible?(user)}
853 notified.reject! {|user| !visible?(user)}
849 notified
854 notified
850 end
855 end
851
856
852 # Returns the email addresses that should be notified
857 # Returns the email addresses that should be notified
853 def recipients
858 def recipients
854 notified_users.collect(&:mail)
859 notified_users.collect(&:mail)
855 end
860 end
856
861
857 def each_notification(users, &block)
862 def each_notification(users, &block)
858 if users.any?
863 if users.any?
859 if custom_field_values.detect {|value| !value.custom_field.visible?}
864 if custom_field_values.detect {|value| !value.custom_field.visible?}
860 users_by_custom_field_visibility = users.group_by do |user|
865 users_by_custom_field_visibility = users.group_by do |user|
861 visible_custom_field_values(user).map(&:custom_field_id).sort
866 visible_custom_field_values(user).map(&:custom_field_id).sort
862 end
867 end
863 users_by_custom_field_visibility.values.each do |users|
868 users_by_custom_field_visibility.values.each do |users|
864 yield(users)
869 yield(users)
865 end
870 end
866 else
871 else
867 yield(users)
872 yield(users)
868 end
873 end
869 end
874 end
870 end
875 end
871
876
872 # Returns the number of hours spent on this issue
877 # Returns the number of hours spent on this issue
873 def spent_hours
878 def spent_hours
874 @spent_hours ||= time_entries.sum(:hours) || 0
879 @spent_hours ||= time_entries.sum(:hours) || 0
875 end
880 end
876
881
877 # Returns the total number of hours spent on this issue and its descendants
882 # Returns the total number of hours spent on this issue and its descendants
878 #
883 #
879 # Example:
884 # Example:
880 # spent_hours => 0.0
885 # spent_hours => 0.0
881 # spent_hours => 50.2
886 # spent_hours => 50.2
882 def total_spent_hours
887 def total_spent_hours
883 @total_spent_hours ||=
888 @total_spent_hours ||=
884 self_and_descendants.
889 self_and_descendants.
885 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
890 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
886 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
891 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
887 end
892 end
888
893
889 def relations
894 def relations
890 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
895 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
891 end
896 end
892
897
893 # Preloads relations for a collection of issues
898 # Preloads relations for a collection of issues
894 def self.load_relations(issues)
899 def self.load_relations(issues)
895 if issues.any?
900 if issues.any?
896 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
901 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
897 issues.each do |issue|
902 issues.each do |issue|
898 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
903 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
899 end
904 end
900 end
905 end
901 end
906 end
902
907
903 # Preloads visible spent time for a collection of issues
908 # Preloads visible spent time for a collection of issues
904 def self.load_visible_spent_hours(issues, user=User.current)
909 def self.load_visible_spent_hours(issues, user=User.current)
905 if issues.any?
910 if issues.any?
906 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
911 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
907 issues.each do |issue|
912 issues.each do |issue|
908 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
913 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
909 end
914 end
910 end
915 end
911 end
916 end
912
917
913 # Preloads visible relations for a collection of issues
918 # Preloads visible relations for a collection of issues
914 def self.load_visible_relations(issues, user=User.current)
919 def self.load_visible_relations(issues, user=User.current)
915 if issues.any?
920 if issues.any?
916 issue_ids = issues.map(&:id)
921 issue_ids = issues.map(&:id)
917 # Relations with issue_from in given issues and visible issue_to
922 # Relations with issue_from in given issues and visible issue_to
918 relations_from = IssueRelation.joins(:issue_to => :project).
923 relations_from = IssueRelation.joins(:issue_to => :project).
919 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
924 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
920 # Relations with issue_to in given issues and visible issue_from
925 # Relations with issue_to in given issues and visible issue_from
921 relations_to = IssueRelation.joins(:issue_from => :project).
926 relations_to = IssueRelation.joins(:issue_from => :project).
922 where(visible_condition(user)).
927 where(visible_condition(user)).
923 where(:issue_to_id => issue_ids).to_a
928 where(:issue_to_id => issue_ids).to_a
924 issues.each do |issue|
929 issues.each do |issue|
925 relations =
930 relations =
926 relations_from.select {|relation| relation.issue_from_id == issue.id} +
931 relations_from.select {|relation| relation.issue_from_id == issue.id} +
927 relations_to.select {|relation| relation.issue_to_id == issue.id}
932 relations_to.select {|relation| relation.issue_to_id == issue.id}
928
933
929 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
934 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
930 end
935 end
931 end
936 end
932 end
937 end
933
938
934 # Finds an issue relation given its id.
939 # Finds an issue relation given its id.
935 def find_relation(relation_id)
940 def find_relation(relation_id)
936 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
941 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
937 end
942 end
938
943
939 # Returns all the other issues that depend on the issue
944 # Returns all the other issues that depend on the issue
940 # The algorithm is a modified breadth first search (bfs)
945 # The algorithm is a modified breadth first search (bfs)
941 def all_dependent_issues(except=[])
946 def all_dependent_issues(except=[])
942 # The found dependencies
947 # The found dependencies
943 dependencies = []
948 dependencies = []
944
949
945 # The visited flag for every node (issue) used by the breadth first search
950 # The visited flag for every node (issue) used by the breadth first search
946 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
951 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
947
952
948 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
953 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
949 # the issue when it is processed.
954 # the issue when it is processed.
950
955
951 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
956 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
952 # but its children will not be added to the queue when it is processed.
957 # but its children will not be added to the queue when it is processed.
953
958
954 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
959 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
955 # the queue, but its children have not been added.
960 # the queue, but its children have not been added.
956
961
957 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
962 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
958 # the children still need to be processed.
963 # the children still need to be processed.
959
964
960 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
965 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
961 # added as dependent issues. It needs no further processing.
966 # added as dependent issues. It needs no further processing.
962
967
963 issue_status = Hash.new(eNOT_DISCOVERED)
968 issue_status = Hash.new(eNOT_DISCOVERED)
964
969
965 # The queue
970 # The queue
966 queue = []
971 queue = []
967
972
968 # Initialize the bfs, add start node (self) to the queue
973 # Initialize the bfs, add start node (self) to the queue
969 queue << self
974 queue << self
970 issue_status[self] = ePROCESS_ALL
975 issue_status[self] = ePROCESS_ALL
971
976
972 while (!queue.empty?) do
977 while (!queue.empty?) do
973 current_issue = queue.shift
978 current_issue = queue.shift
974 current_issue_status = issue_status[current_issue]
979 current_issue_status = issue_status[current_issue]
975 dependencies << current_issue
980 dependencies << current_issue
976
981
977 # Add parent to queue, if not already in it.
982 # Add parent to queue, if not already in it.
978 parent = current_issue.parent
983 parent = current_issue.parent
979 parent_status = issue_status[parent]
984 parent_status = issue_status[parent]
980
985
981 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
986 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
982 queue << parent
987 queue << parent
983 issue_status[parent] = ePROCESS_RELATIONS_ONLY
988 issue_status[parent] = ePROCESS_RELATIONS_ONLY
984 end
989 end
985
990
986 # Add children to queue, but only if they are not already in it and
991 # Add children to queue, but only if they are not already in it and
987 # the children of the current node need to be processed.
992 # the children of the current node need to be processed.
988 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
993 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
989 current_issue.children.each do |child|
994 current_issue.children.each do |child|
990 next if except.include?(child)
995 next if except.include?(child)
991
996
992 if (issue_status[child] == eNOT_DISCOVERED)
997 if (issue_status[child] == eNOT_DISCOVERED)
993 queue << child
998 queue << child
994 issue_status[child] = ePROCESS_ALL
999 issue_status[child] = ePROCESS_ALL
995 elsif (issue_status[child] == eRELATIONS_PROCESSED)
1000 elsif (issue_status[child] == eRELATIONS_PROCESSED)
996 queue << child
1001 queue << child
997 issue_status[child] = ePROCESS_CHILDREN_ONLY
1002 issue_status[child] = ePROCESS_CHILDREN_ONLY
998 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1003 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
999 queue << child
1004 queue << child
1000 issue_status[child] = ePROCESS_ALL
1005 issue_status[child] = ePROCESS_ALL
1001 end
1006 end
1002 end
1007 end
1003 end
1008 end
1004
1009
1005 # Add related issues to the queue, if they are not already in it.
1010 # Add related issues to the queue, if they are not already in it.
1006 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1011 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1007 next if except.include?(related_issue)
1012 next if except.include?(related_issue)
1008
1013
1009 if (issue_status[related_issue] == eNOT_DISCOVERED)
1014 if (issue_status[related_issue] == eNOT_DISCOVERED)
1010 queue << related_issue
1015 queue << related_issue
1011 issue_status[related_issue] = ePROCESS_ALL
1016 issue_status[related_issue] = ePROCESS_ALL
1012 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1017 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1013 queue << related_issue
1018 queue << related_issue
1014 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1019 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1015 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1020 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1016 queue << related_issue
1021 queue << related_issue
1017 issue_status[related_issue] = ePROCESS_ALL
1022 issue_status[related_issue] = ePROCESS_ALL
1018 end
1023 end
1019 end
1024 end
1020
1025
1021 # Set new status for current issue
1026 # Set new status for current issue
1022 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1027 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1023 issue_status[current_issue] = eALL_PROCESSED
1028 issue_status[current_issue] = eALL_PROCESSED
1024 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1029 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1025 issue_status[current_issue] = eRELATIONS_PROCESSED
1030 issue_status[current_issue] = eRELATIONS_PROCESSED
1026 end
1031 end
1027 end # while
1032 end # while
1028
1033
1029 # Remove the issues from the "except" parameter from the result array
1034 # Remove the issues from the "except" parameter from the result array
1030 dependencies -= except
1035 dependencies -= except
1031 dependencies.delete(self)
1036 dependencies.delete(self)
1032
1037
1033 dependencies
1038 dependencies
1034 end
1039 end
1035
1040
1036 # Returns an array of issues that duplicate this one
1041 # Returns an array of issues that duplicate this one
1037 def duplicates
1042 def duplicates
1038 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1043 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1039 end
1044 end
1040
1045
1041 # Returns the due date or the target due date if any
1046 # Returns the due date or the target due date if any
1042 # Used on gantt chart
1047 # Used on gantt chart
1043 def due_before
1048 def due_before
1044 due_date || (fixed_version ? fixed_version.effective_date : nil)
1049 due_date || (fixed_version ? fixed_version.effective_date : nil)
1045 end
1050 end
1046
1051
1047 # Returns the time scheduled for this issue.
1052 # Returns the time scheduled for this issue.
1048 #
1053 #
1049 # Example:
1054 # Example:
1050 # Start Date: 2/26/09, End Date: 3/04/09
1055 # Start Date: 2/26/09, End Date: 3/04/09
1051 # duration => 6
1056 # duration => 6
1052 def duration
1057 def duration
1053 (start_date && due_date) ? due_date - start_date : 0
1058 (start_date && due_date) ? due_date - start_date : 0
1054 end
1059 end
1055
1060
1056 # Returns the duration in working days
1061 # Returns the duration in working days
1057 def working_duration
1062 def working_duration
1058 (start_date && due_date) ? working_days(start_date, due_date) : 0
1063 (start_date && due_date) ? working_days(start_date, due_date) : 0
1059 end
1064 end
1060
1065
1061 def soonest_start(reload=false)
1066 def soonest_start(reload=false)
1062 @soonest_start = nil if reload
1067 @soonest_start = nil if reload
1063 @soonest_start ||= (
1068 @soonest_start ||= (
1064 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1069 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1065 [(@parent_issue || parent).try(:soonest_start)]
1070 [(@parent_issue || parent).try(:soonest_start)]
1066 ).compact.max
1071 ).compact.max
1067 end
1072 end
1068
1073
1069 # Sets start_date on the given date or the next working day
1074 # Sets start_date on the given date or the next working day
1070 # and changes due_date to keep the same working duration.
1075 # and changes due_date to keep the same working duration.
1071 def reschedule_on(date)
1076 def reschedule_on(date)
1072 wd = working_duration
1077 wd = working_duration
1073 date = next_working_date(date)
1078 date = next_working_date(date)
1074 self.start_date = date
1079 self.start_date = date
1075 self.due_date = add_working_days(date, wd)
1080 self.due_date = add_working_days(date, wd)
1076 end
1081 end
1077
1082
1078 # Reschedules the issue on the given date or the next working day and saves the record.
1083 # Reschedules the issue on the given date or the next working day and saves the record.
1079 # If the issue is a parent task, this is done by rescheduling its subtasks.
1084 # If the issue is a parent task, this is done by rescheduling its subtasks.
1080 def reschedule_on!(date)
1085 def reschedule_on!(date)
1081 return if date.nil?
1086 return if date.nil?
1082 if leaf?
1087 if leaf?
1083 if start_date.nil? || start_date != date
1088 if start_date.nil? || start_date != date
1084 if start_date && start_date > date
1089 if start_date && start_date > date
1085 # Issue can not be moved earlier than its soonest start date
1090 # Issue can not be moved earlier than its soonest start date
1086 date = [soonest_start(true), date].compact.max
1091 date = [soonest_start(true), date].compact.max
1087 end
1092 end
1088 reschedule_on(date)
1093 reschedule_on(date)
1089 begin
1094 begin
1090 save
1095 save
1091 rescue ActiveRecord::StaleObjectError
1096 rescue ActiveRecord::StaleObjectError
1092 reload
1097 reload
1093 reschedule_on(date)
1098 reschedule_on(date)
1094 save
1099 save
1095 end
1100 end
1096 end
1101 end
1097 else
1102 else
1098 leaves.each do |leaf|
1103 leaves.each do |leaf|
1099 if leaf.start_date
1104 if leaf.start_date
1100 # Only move subtask if it starts at the same date as the parent
1105 # Only move subtask if it starts at the same date as the parent
1101 # or if it starts before the given date
1106 # or if it starts before the given date
1102 if start_date == leaf.start_date || date > leaf.start_date
1107 if start_date == leaf.start_date || date > leaf.start_date
1103 leaf.reschedule_on!(date)
1108 leaf.reschedule_on!(date)
1104 end
1109 end
1105 else
1110 else
1106 leaf.reschedule_on!(date)
1111 leaf.reschedule_on!(date)
1107 end
1112 end
1108 end
1113 end
1109 end
1114 end
1110 end
1115 end
1111
1116
1112 def <=>(issue)
1117 def <=>(issue)
1113 if issue.nil?
1118 if issue.nil?
1114 -1
1119 -1
1115 elsif root_id != issue.root_id
1120 elsif root_id != issue.root_id
1116 (root_id || 0) <=> (issue.root_id || 0)
1121 (root_id || 0) <=> (issue.root_id || 0)
1117 else
1122 else
1118 (lft || 0) <=> (issue.lft || 0)
1123 (lft || 0) <=> (issue.lft || 0)
1119 end
1124 end
1120 end
1125 end
1121
1126
1122 def to_s
1127 def to_s
1123 "#{tracker} ##{id}: #{subject}"
1128 "#{tracker} ##{id}: #{subject}"
1124 end
1129 end
1125
1130
1126 # Returns a string of css classes that apply to the issue
1131 # Returns a string of css classes that apply to the issue
1127 def css_classes(user=User.current)
1132 def css_classes(user=User.current)
1128 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1133 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1129 s << ' closed' if closed?
1134 s << ' closed' if closed?
1130 s << ' overdue' if overdue?
1135 s << ' overdue' if overdue?
1131 s << ' child' if child?
1136 s << ' child' if child?
1132 s << ' parent' unless leaf?
1137 s << ' parent' unless leaf?
1133 s << ' private' if is_private?
1138 s << ' private' if is_private?
1134 if user.logged?
1139 if user.logged?
1135 s << ' created-by-me' if author_id == user.id
1140 s << ' created-by-me' if author_id == user.id
1136 s << ' assigned-to-me' if assigned_to_id == user.id
1141 s << ' assigned-to-me' if assigned_to_id == user.id
1137 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1142 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1138 end
1143 end
1139 s
1144 s
1140 end
1145 end
1141
1146
1142 # Unassigns issues from +version+ if it's no longer shared with issue's project
1147 # Unassigns issues from +version+ if it's no longer shared with issue's project
1143 def self.update_versions_from_sharing_change(version)
1148 def self.update_versions_from_sharing_change(version)
1144 # Update issues assigned to the version
1149 # Update issues assigned to the version
1145 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1150 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1146 end
1151 end
1147
1152
1148 # Unassigns issues from versions that are no longer shared
1153 # Unassigns issues from versions that are no longer shared
1149 # after +project+ was moved
1154 # after +project+ was moved
1150 def self.update_versions_from_hierarchy_change(project)
1155 def self.update_versions_from_hierarchy_change(project)
1151 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1156 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1152 # Update issues of the moved projects and issues assigned to a version of a moved project
1157 # Update issues of the moved projects and issues assigned to a version of a moved project
1153 Issue.update_versions(
1158 Issue.update_versions(
1154 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1159 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1155 moved_project_ids, moved_project_ids]
1160 moved_project_ids, moved_project_ids]
1156 )
1161 )
1157 end
1162 end
1158
1163
1159 def parent_issue_id=(arg)
1164 def parent_issue_id=(arg)
1160 s = arg.to_s.strip.presence
1165 s = arg.to_s.strip.presence
1161 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1166 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1162 @invalid_parent_issue_id = nil
1167 @invalid_parent_issue_id = nil
1163 elsif s.blank?
1168 elsif s.blank?
1164 @parent_issue = nil
1169 @parent_issue = nil
1165 @invalid_parent_issue_id = nil
1170 @invalid_parent_issue_id = nil
1166 else
1171 else
1167 @parent_issue = nil
1172 @parent_issue = nil
1168 @invalid_parent_issue_id = arg
1173 @invalid_parent_issue_id = arg
1169 end
1174 end
1170 end
1175 end
1171
1176
1172 def parent_issue_id
1177 def parent_issue_id
1173 if @invalid_parent_issue_id
1178 if @invalid_parent_issue_id
1174 @invalid_parent_issue_id
1179 @invalid_parent_issue_id
1175 elsif instance_variable_defined? :@parent_issue
1180 elsif instance_variable_defined? :@parent_issue
1176 @parent_issue.nil? ? nil : @parent_issue.id
1181 @parent_issue.nil? ? nil : @parent_issue.id
1177 else
1182 else
1178 parent_id
1183 parent_id
1179 end
1184 end
1180 end
1185 end
1181
1186
1182 def set_parent_id
1187 def set_parent_id
1183 self.parent_id = parent_issue_id
1188 self.parent_id = parent_issue_id
1184 end
1189 end
1185
1190
1186 # Returns true if issue's project is a valid
1191 # Returns true if issue's project is a valid
1187 # parent issue project
1192 # parent issue project
1188 def valid_parent_project?(issue=parent)
1193 def valid_parent_project?(issue=parent)
1189 return true if issue.nil? || issue.project_id == project_id
1194 return true if issue.nil? || issue.project_id == project_id
1190
1195
1191 case Setting.cross_project_subtasks
1196 case Setting.cross_project_subtasks
1192 when 'system'
1197 when 'system'
1193 true
1198 true
1194 when 'tree'
1199 when 'tree'
1195 issue.project.root == project.root
1200 issue.project.root == project.root
1196 when 'hierarchy'
1201 when 'hierarchy'
1197 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1202 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1198 when 'descendants'
1203 when 'descendants'
1199 issue.project.is_or_is_ancestor_of?(project)
1204 issue.project.is_or_is_ancestor_of?(project)
1200 else
1205 else
1201 false
1206 false
1202 end
1207 end
1203 end
1208 end
1204
1209
1205 # Returns an issue scope based on project and scope
1210 # Returns an issue scope based on project and scope
1206 def self.cross_project_scope(project, scope=nil)
1211 def self.cross_project_scope(project, scope=nil)
1207 if project.nil?
1212 if project.nil?
1208 return Issue
1213 return Issue
1209 end
1214 end
1210 case scope
1215 case scope
1211 when 'all', 'system'
1216 when 'all', 'system'
1212 Issue
1217 Issue
1213 when 'tree'
1218 when 'tree'
1214 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1219 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1215 :lft => project.root.lft, :rgt => project.root.rgt)
1220 :lft => project.root.lft, :rgt => project.root.rgt)
1216 when 'hierarchy'
1221 when 'hierarchy'
1217 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)",
1222 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)",
1218 :lft => project.lft, :rgt => project.rgt)
1223 :lft => project.lft, :rgt => project.rgt)
1219 when 'descendants'
1224 when 'descendants'
1220 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1225 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1221 :lft => project.lft, :rgt => project.rgt)
1226 :lft => project.lft, :rgt => project.rgt)
1222 else
1227 else
1223 Issue.where(:project_id => project.id)
1228 Issue.where(:project_id => project.id)
1224 end
1229 end
1225 end
1230 end
1226
1231
1227 def self.by_tracker(project)
1232 def self.by_tracker(project)
1228 count_and_group_by(:project => project, :association => :tracker)
1233 count_and_group_by(:project => project, :association => :tracker)
1229 end
1234 end
1230
1235
1231 def self.by_version(project)
1236 def self.by_version(project)
1232 count_and_group_by(:project => project, :association => :fixed_version)
1237 count_and_group_by(:project => project, :association => :fixed_version)
1233 end
1238 end
1234
1239
1235 def self.by_priority(project)
1240 def self.by_priority(project)
1236 count_and_group_by(:project => project, :association => :priority)
1241 count_and_group_by(:project => project, :association => :priority)
1237 end
1242 end
1238
1243
1239 def self.by_category(project)
1244 def self.by_category(project)
1240 count_and_group_by(:project => project, :association => :category)
1245 count_and_group_by(:project => project, :association => :category)
1241 end
1246 end
1242
1247
1243 def self.by_assigned_to(project)
1248 def self.by_assigned_to(project)
1244 count_and_group_by(:project => project, :association => :assigned_to)
1249 count_and_group_by(:project => project, :association => :assigned_to)
1245 end
1250 end
1246
1251
1247 def self.by_author(project)
1252 def self.by_author(project)
1248 count_and_group_by(:project => project, :association => :author)
1253 count_and_group_by(:project => project, :association => :author)
1249 end
1254 end
1250
1255
1251 def self.by_subproject(project)
1256 def self.by_subproject(project)
1252 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1257 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1253 r.reject {|r| r["project_id"] == project.id.to_s}
1258 r.reject {|r| r["project_id"] == project.id.to_s}
1254 end
1259 end
1255
1260
1256 # Query generator for selecting groups of issue counts for a project
1261 # Query generator for selecting groups of issue counts for a project
1257 # based on specific criteria
1262 # based on specific criteria
1258 #
1263 #
1259 # Options
1264 # Options
1260 # * project - Project to search in.
1265 # * project - Project to search in.
1261 # * with_subprojects - Includes subprojects issues if set to true.
1266 # * with_subprojects - Includes subprojects issues if set to true.
1262 # * association - Symbol. Association for grouping.
1267 # * association - Symbol. Association for grouping.
1263 def self.count_and_group_by(options)
1268 def self.count_and_group_by(options)
1264 assoc = reflect_on_association(options[:association])
1269 assoc = reflect_on_association(options[:association])
1265 select_field = assoc.foreign_key
1270 select_field = assoc.foreign_key
1266
1271
1267 Issue.
1272 Issue.
1268 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1273 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1269 joins(:status, assoc.name).
1274 joins(:status, assoc.name).
1270 group(:status_id, :is_closed, select_field).
1275 group(:status_id, :is_closed, select_field).
1271 count.
1276 count.
1272 map do |columns, total|
1277 map do |columns, total|
1273 status_id, is_closed, field_value = columns
1278 status_id, is_closed, field_value = columns
1274 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1279 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1275 {
1280 {
1276 "status_id" => status_id.to_s,
1281 "status_id" => status_id.to_s,
1277 "closed" => is_closed,
1282 "closed" => is_closed,
1278 select_field => field_value.to_s,
1283 select_field => field_value.to_s,
1279 "total" => total.to_s
1284 "total" => total.to_s
1280 }
1285 }
1281 end
1286 end
1282 end
1287 end
1283
1288
1284 # Returns a scope of projects that user can assign the issue to
1289 # Returns a scope of projects that user can assign the issue to
1285 def allowed_target_projects(user=User.current)
1290 def allowed_target_projects(user=User.current)
1286 current_project = new_record? ? nil : project
1291 current_project = new_record? ? nil : project
1287 self.class.allowed_target_projects(user, current_project)
1292 self.class.allowed_target_projects(user, current_project)
1288 end
1293 end
1289
1294
1290 # Returns a scope of projects that user can assign issues to
1295 # Returns a scope of projects that user can assign issues to
1291 # If current_project is given, it will be included in the scope
1296 # If current_project is given, it will be included in the scope
1292 def self.allowed_target_projects(user=User.current, current_project=nil)
1297 def self.allowed_target_projects(user=User.current, current_project=nil)
1293 condition = Project.allowed_to_condition(user, :add_issues)
1298 condition = Project.allowed_to_condition(user, :add_issues)
1294 if current_project
1299 if current_project
1295 condition = ["(#{condition}) OR #{Project.table_name}.id = ?", current_project.id]
1300 condition = ["(#{condition}) OR #{Project.table_name}.id = ?", current_project.id]
1296 end
1301 end
1297 Project.where(condition)
1302 Project.where(condition)
1298 end
1303 end
1299
1304
1300 private
1305 private
1301
1306
1302 def after_project_change
1307 def after_project_change
1303 # Update project_id on related time entries
1308 # Update project_id on related time entries
1304 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1309 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1305
1310
1306 # Delete issue relations
1311 # Delete issue relations
1307 unless Setting.cross_project_issue_relations?
1312 unless Setting.cross_project_issue_relations?
1308 relations_from.clear
1313 relations_from.clear
1309 relations_to.clear
1314 relations_to.clear
1310 end
1315 end
1311
1316
1312 # Move subtasks that were in the same project
1317 # Move subtasks that were in the same project
1313 children.each do |child|
1318 children.each do |child|
1314 next unless child.project_id == project_id_was
1319 next unless child.project_id == project_id_was
1315 # Change project and keep project
1320 # Change project and keep project
1316 child.send :project=, project, true
1321 child.send :project=, project, true
1317 unless child.save
1322 unless child.save
1318 raise ActiveRecord::Rollback
1323 raise ActiveRecord::Rollback
1319 end
1324 end
1320 end
1325 end
1321 end
1326 end
1322
1327
1323 # Callback for after the creation of an issue by copy
1328 # Callback for after the creation of an issue by copy
1324 # * adds a "copied to" relation with the copied issue
1329 # * adds a "copied to" relation with the copied issue
1325 # * copies subtasks from the copied issue
1330 # * copies subtasks from the copied issue
1326 def after_create_from_copy
1331 def after_create_from_copy
1327 return unless copy? && !@after_create_from_copy_handled
1332 return unless copy? && !@after_create_from_copy_handled
1328
1333
1329 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1334 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1330 if @current_journal
1335 if @current_journal
1331 @copied_from.init_journal(@current_journal.user)
1336 @copied_from.init_journal(@current_journal.user)
1332 end
1337 end
1333 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1338 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1334 unless relation.save
1339 unless relation.save
1335 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1340 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1336 end
1341 end
1337 end
1342 end
1338
1343
1339 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1344 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1340 copy_options = (@copy_options || {}).merge(:subtasks => false)
1345 copy_options = (@copy_options || {}).merge(:subtasks => false)
1341 copied_issue_ids = {@copied_from.id => self.id}
1346 copied_issue_ids = {@copied_from.id => self.id}
1342 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1347 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1343 # Do not copy self when copying an issue as a descendant of the copied issue
1348 # Do not copy self when copying an issue as a descendant of the copied issue
1344 next if child == self
1349 next if child == self
1345 # Do not copy subtasks of issues that were not copied
1350 # Do not copy subtasks of issues that were not copied
1346 next unless copied_issue_ids[child.parent_id]
1351 next unless copied_issue_ids[child.parent_id]
1347 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1352 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1348 unless child.visible?
1353 unless child.visible?
1349 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1354 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1350 next
1355 next
1351 end
1356 end
1352 copy = Issue.new.copy_from(child, copy_options)
1357 copy = Issue.new.copy_from(child, copy_options)
1353 if @current_journal
1358 if @current_journal
1354 copy.init_journal(@current_journal.user)
1359 copy.init_journal(@current_journal.user)
1355 end
1360 end
1356 copy.author = author
1361 copy.author = author
1357 copy.project = project
1362 copy.project = project
1358 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1363 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1359 unless copy.save
1364 unless copy.save
1360 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
1365 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
1361 next
1366 next
1362 end
1367 end
1363 copied_issue_ids[child.id] = copy.id
1368 copied_issue_ids[child.id] = copy.id
1364 end
1369 end
1365 end
1370 end
1366 @after_create_from_copy_handled = true
1371 @after_create_from_copy_handled = true
1367 end
1372 end
1368
1373
1369 def update_nested_set_attributes
1374 def update_nested_set_attributes
1370 if parent_id_changed?
1375 if parent_id_changed?
1371 update_nested_set_attributes_on_parent_change
1376 update_nested_set_attributes_on_parent_change
1372 end
1377 end
1373 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1378 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1374 end
1379 end
1375
1380
1376 # Updates the nested set for when an existing issue is moved
1381 # Updates the nested set for when an existing issue is moved
1377 def update_nested_set_attributes_on_parent_change
1382 def update_nested_set_attributes_on_parent_change
1378 former_parent_id = parent_id_was
1383 former_parent_id = parent_id_was
1379 # delete invalid relations of all descendants
1384 # delete invalid relations of all descendants
1380 self_and_descendants.each do |issue|
1385 self_and_descendants.each do |issue|
1381 issue.relations.each do |relation|
1386 issue.relations.each do |relation|
1382 relation.destroy unless relation.valid?
1387 relation.destroy unless relation.valid?
1383 end
1388 end
1384 end
1389 end
1385 # update former parent
1390 # update former parent
1386 recalculate_attributes_for(former_parent_id) if former_parent_id
1391 recalculate_attributes_for(former_parent_id) if former_parent_id
1387 end
1392 end
1388
1393
1389 def update_parent_attributes
1394 def update_parent_attributes
1390 if parent_id
1395 if parent_id
1391 recalculate_attributes_for(parent_id)
1396 recalculate_attributes_for(parent_id)
1392 association(:parent).reset
1397 association(:parent).reset
1393 end
1398 end
1394 end
1399 end
1395
1400
1396 def recalculate_attributes_for(issue_id)
1401 def recalculate_attributes_for(issue_id)
1397 if issue_id && p = Issue.find_by_id(issue_id)
1402 if issue_id && p = Issue.find_by_id(issue_id)
1398 # priority = highest priority of children
1403 # priority = highest priority of children
1399 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1404 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1400 p.priority = IssuePriority.find_by_position(priority_position)
1405 p.priority = IssuePriority.find_by_position(priority_position)
1401 end
1406 end
1402
1407
1403 # start/due dates = lowest/highest dates of children
1408 # start/due dates = lowest/highest dates of children
1404 p.start_date = p.children.minimum(:start_date)
1409 p.start_date = p.children.minimum(:start_date)
1405 p.due_date = p.children.maximum(:due_date)
1410 p.due_date = p.children.maximum(:due_date)
1406 if p.start_date && p.due_date && p.due_date < p.start_date
1411 if p.start_date && p.due_date && p.due_date < p.start_date
1407 p.start_date, p.due_date = p.due_date, p.start_date
1412 p.start_date, p.due_date = p.due_date, p.start_date
1408 end
1413 end
1409
1414
1410 # done ratio = weighted average ratio of leaves
1415 # done ratio = weighted average ratio of leaves
1411 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1416 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1412 leaves_count = p.leaves.count
1417 leaves_count = p.leaves.count
1413 if leaves_count > 0
1418 if leaves_count > 0
1414 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1419 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1415 if average == 0
1420 if average == 0
1416 average = 1
1421 average = 1
1417 end
1422 end
1418 done = p.leaves.joins(:status).
1423 done = p.leaves.joins(:status).
1419 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1424 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1420 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1425 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1421 progress = done / (average * leaves_count)
1426 progress = done / (average * leaves_count)
1422 p.done_ratio = progress.round
1427 p.done_ratio = progress.round
1423 end
1428 end
1424 end
1429 end
1425
1430
1426 # estimate = sum of leaves estimates
1431 # estimate = sum of leaves estimates
1427 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1432 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1428 p.estimated_hours = nil if p.estimated_hours == 0.0
1433 p.estimated_hours = nil if p.estimated_hours == 0.0
1429
1434
1430 # ancestors will be recursively updated
1435 # ancestors will be recursively updated
1431 p.save(:validate => false)
1436 p.save(:validate => false)
1432 end
1437 end
1433 end
1438 end
1434
1439
1435 # Update issues so their versions are not pointing to a
1440 # Update issues so their versions are not pointing to a
1436 # fixed_version that is not shared with the issue's project
1441 # fixed_version that is not shared with the issue's project
1437 def self.update_versions(conditions=nil)
1442 def self.update_versions(conditions=nil)
1438 # Only need to update issues with a fixed_version from
1443 # Only need to update issues with a fixed_version from
1439 # a different project and that is not systemwide shared
1444 # a different project and that is not systemwide shared
1440 Issue.joins(:project, :fixed_version).
1445 Issue.joins(:project, :fixed_version).
1441 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1446 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1442 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1447 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1443 " AND #{Version.table_name}.sharing <> 'system'").
1448 " AND #{Version.table_name}.sharing <> 'system'").
1444 where(conditions).each do |issue|
1449 where(conditions).each do |issue|
1445 next if issue.project.nil? || issue.fixed_version.nil?
1450 next if issue.project.nil? || issue.fixed_version.nil?
1446 unless issue.project.shared_versions.include?(issue.fixed_version)
1451 unless issue.project.shared_versions.include?(issue.fixed_version)
1447 issue.init_journal(User.current)
1452 issue.init_journal(User.current)
1448 issue.fixed_version = nil
1453 issue.fixed_version = nil
1449 issue.save
1454 issue.save
1450 end
1455 end
1451 end
1456 end
1452 end
1457 end
1453
1458
1454 # Callback on file attachment
1459 # Callback on file attachment
1455 def attachment_added(attachment)
1460 def attachment_added(attachment)
1456 if current_journal && !attachment.new_record?
1461 if current_journal && !attachment.new_record?
1457 current_journal.journalize_attachment(attachment, :added)
1462 current_journal.journalize_attachment(attachment, :added)
1458 end
1463 end
1459 end
1464 end
1460
1465
1461 # Callback on attachment deletion
1466 # Callback on attachment deletion
1462 def attachment_removed(attachment)
1467 def attachment_removed(attachment)
1463 if current_journal && !attachment.new_record?
1468 if current_journal && !attachment.new_record?
1464 current_journal.journalize_attachment(attachment, :removed)
1469 current_journal.journalize_attachment(attachment, :removed)
1465 current_journal.save
1470 current_journal.save
1466 end
1471 end
1467 end
1472 end
1468
1473
1469 # Called after a relation is added
1474 # Called after a relation is added
1470 def relation_added(relation)
1475 def relation_added(relation)
1471 if current_journal
1476 if current_journal
1472 current_journal.journalize_relation(relation, :added)
1477 current_journal.journalize_relation(relation, :added)
1473 current_journal.save
1478 current_journal.save
1474 end
1479 end
1475 end
1480 end
1476
1481
1477 # Called after a relation is removed
1482 # Called after a relation is removed
1478 def relation_removed(relation)
1483 def relation_removed(relation)
1479 if current_journal
1484 if current_journal
1480 current_journal.journalize_relation(relation, :removed)
1485 current_journal.journalize_relation(relation, :removed)
1481 current_journal.save
1486 current_journal.save
1482 end
1487 end
1483 end
1488 end
1484
1489
1485 # Default assignment based on category
1490 # Default assignment based on category
1486 def default_assign
1491 def default_assign
1487 if assigned_to.nil? && category && category.assigned_to
1492 if assigned_to.nil? && category && category.assigned_to
1488 self.assigned_to = category.assigned_to
1493 self.assigned_to = category.assigned_to
1489 end
1494 end
1490 end
1495 end
1491
1496
1492 # Updates start/due dates of following issues
1497 # Updates start/due dates of following issues
1493 def reschedule_following_issues
1498 def reschedule_following_issues
1494 if start_date_changed? || due_date_changed?
1499 if start_date_changed? || due_date_changed?
1495 relations_from.each do |relation|
1500 relations_from.each do |relation|
1496 relation.set_issue_to_dates
1501 relation.set_issue_to_dates
1497 end
1502 end
1498 end
1503 end
1499 end
1504 end
1500
1505
1501 # Closes duplicates if the issue is being closed
1506 # Closes duplicates if the issue is being closed
1502 def close_duplicates
1507 def close_duplicates
1503 if closing?
1508 if closing?
1504 duplicates.each do |duplicate|
1509 duplicates.each do |duplicate|
1505 # Reload is needed in case the duplicate was updated by a previous duplicate
1510 # Reload is needed in case the duplicate was updated by a previous duplicate
1506 duplicate.reload
1511 duplicate.reload
1507 # Don't re-close it if it's already closed
1512 # Don't re-close it if it's already closed
1508 next if duplicate.closed?
1513 next if duplicate.closed?
1509 # Same user and notes
1514 # Same user and notes
1510 if @current_journal
1515 if @current_journal
1511 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1516 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1512 end
1517 end
1513 duplicate.update_attribute :status, self.status
1518 duplicate.update_attribute :status, self.status
1514 end
1519 end
1515 end
1520 end
1516 end
1521 end
1517
1522
1518 # Make sure updated_on is updated when adding a note and set updated_on now
1523 # Make sure updated_on is updated when adding a note and set updated_on now
1519 # so we can set closed_on with the same value on closing
1524 # so we can set closed_on with the same value on closing
1520 def force_updated_on_change
1525 def force_updated_on_change
1521 if @current_journal || changed?
1526 if @current_journal || changed?
1522 self.updated_on = current_time_from_proper_timezone
1527 self.updated_on = current_time_from_proper_timezone
1523 if new_record?
1528 if new_record?
1524 self.created_on = updated_on
1529 self.created_on = updated_on
1525 end
1530 end
1526 end
1531 end
1527 end
1532 end
1528
1533
1529 # Callback for setting closed_on when the issue is closed.
1534 # Callback for setting closed_on when the issue is closed.
1530 # The closed_on attribute stores the time of the last closing
1535 # The closed_on attribute stores the time of the last closing
1531 # and is preserved when the issue is reopened.
1536 # and is preserved when the issue is reopened.
1532 def update_closed_on
1537 def update_closed_on
1533 if closing?
1538 if closing?
1534 self.closed_on = updated_on
1539 self.closed_on = updated_on
1535 end
1540 end
1536 end
1541 end
1537
1542
1538 # Saves the changes in a Journal
1543 # Saves the changes in a Journal
1539 # Called after_save
1544 # Called after_save
1540 def create_journal
1545 def create_journal
1541 if current_journal
1546 if current_journal
1542 current_journal.save
1547 current_journal.save
1543 end
1548 end
1544 end
1549 end
1545
1550
1546 def send_notification
1551 def send_notification
1547 if Setting.notified_events.include?('issue_added')
1552 if Setting.notified_events.include?('issue_added')
1548 Mailer.deliver_issue_add(self)
1553 Mailer.deliver_issue_add(self)
1549 end
1554 end
1550 end
1555 end
1551
1556
1552 # Stores the previous assignee so we can still have access
1557 # Stores the previous assignee so we can still have access
1553 # to it during after_save callbacks (assigned_to_id_was is reset)
1558 # to it during after_save callbacks (assigned_to_id_was is reset)
1554 def set_assigned_to_was
1559 def set_assigned_to_was
1555 @previous_assigned_to_id = assigned_to_id_was
1560 @previous_assigned_to_id = assigned_to_id_was
1556 end
1561 end
1557
1562
1558 # Clears the previous assignee at the end of after_save callbacks
1563 # Clears the previous assignee at the end of after_save callbacks
1559 def clear_assigned_to_was
1564 def clear_assigned_to_was
1560 @assigned_to_was = nil
1565 @assigned_to_was = nil
1561 @previous_assigned_to_id = nil
1566 @previous_assigned_to_id = nil
1562 end
1567 end
1563 end
1568 end
@@ -1,659 +1,688
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 failed update" do
499 test "PUT /issues/:id.xml with failed update" do
471 put '/issues/6.xml', {:issue => {:subject => ''}}, credentials('jsmith')
500 put '/issues/6.xml', {:issue => {:subject => ''}}, credentials('jsmith')
472
501
473 assert_response :unprocessable_entity
502 assert_response :unprocessable_entity
474 assert_select 'errors error', :text => "Subject cannot be blank"
503 assert_select 'errors error', :text => "Subject cannot be blank"
475 end
504 end
476
505
477 test "PUT /issues/:id.json" do
506 test "PUT /issues/:id.json" do
478 assert_difference('Journal.count') do
507 assert_difference('Journal.count') do
479 put '/issues/6.json',
508 put '/issues/6.json',
480 {:issue => {:subject => 'API update', :notes => 'A new note'}},
509 {:issue => {:subject => 'API update', :notes => 'A new note'}},
481 credentials('jsmith')
510 credentials('jsmith')
482
511
483 assert_response :ok
512 assert_response :ok
484 assert_equal '', response.body
513 assert_equal '', response.body
485 end
514 end
486
515
487 issue = Issue.find(6)
516 issue = Issue.find(6)
488 assert_equal "API update", issue.subject
517 assert_equal "API update", issue.subject
489 journal = Journal.last
518 journal = Journal.last
490 assert_equal "A new note", journal.notes
519 assert_equal "A new note", journal.notes
491 end
520 end
492
521
493 test "PUT /issues/:id.json with failed update" do
522 test "PUT /issues/:id.json with failed update" do
494 put '/issues/6.json', {:issue => {:subject => ''}}, credentials('jsmith')
523 put '/issues/6.json', {:issue => {:subject => ''}}, credentials('jsmith')
495
524
496 assert_response :unprocessable_entity
525 assert_response :unprocessable_entity
497 json = ActiveSupport::JSON.decode(response.body)
526 json = ActiveSupport::JSON.decode(response.body)
498 assert json['errors'].include?("Subject cannot be blank")
527 assert json['errors'].include?("Subject cannot be blank")
499 end
528 end
500
529
501 test "DELETE /issues/:id.xml" do
530 test "DELETE /issues/:id.xml" do
502 assert_difference('Issue.count', -1) do
531 assert_difference('Issue.count', -1) do
503 delete '/issues/6.xml', {}, credentials('jsmith')
532 delete '/issues/6.xml', {}, credentials('jsmith')
504
533
505 assert_response :ok
534 assert_response :ok
506 assert_equal '', response.body
535 assert_equal '', response.body
507 end
536 end
508 assert_nil Issue.find_by_id(6)
537 assert_nil Issue.find_by_id(6)
509 end
538 end
510
539
511 test "DELETE /issues/:id.json" do
540 test "DELETE /issues/:id.json" do
512 assert_difference('Issue.count', -1) do
541 assert_difference('Issue.count', -1) do
513 delete '/issues/6.json', {}, credentials('jsmith')
542 delete '/issues/6.json', {}, credentials('jsmith')
514
543
515 assert_response :ok
544 assert_response :ok
516 assert_equal '', response.body
545 assert_equal '', response.body
517 end
546 end
518 assert_nil Issue.find_by_id(6)
547 assert_nil Issue.find_by_id(6)
519 end
548 end
520
549
521 test "POST /issues/:id/watchers.xml should add watcher" do
550 test "POST /issues/:id/watchers.xml should add watcher" do
522 assert_difference 'Watcher.count' do
551 assert_difference 'Watcher.count' do
523 post '/issues/1/watchers.xml', {:user_id => 3}, credentials('jsmith')
552 post '/issues/1/watchers.xml', {:user_id => 3}, credentials('jsmith')
524
553
525 assert_response :ok
554 assert_response :ok
526 assert_equal '', response.body
555 assert_equal '', response.body
527 end
556 end
528 watcher = Watcher.order('id desc').first
557 watcher = Watcher.order('id desc').first
529 assert_equal Issue.find(1), watcher.watchable
558 assert_equal Issue.find(1), watcher.watchable
530 assert_equal User.find(3), watcher.user
559 assert_equal User.find(3), watcher.user
531 end
560 end
532
561
533 test "DELETE /issues/:id/watchers/:user_id.xml should remove watcher" do
562 test "DELETE /issues/:id/watchers/:user_id.xml should remove watcher" do
534 Watcher.create!(:user_id => 3, :watchable => Issue.find(1))
563 Watcher.create!(:user_id => 3, :watchable => Issue.find(1))
535
564
536 assert_difference 'Watcher.count', -1 do
565 assert_difference 'Watcher.count', -1 do
537 delete '/issues/1/watchers/3.xml', {}, credentials('jsmith')
566 delete '/issues/1/watchers/3.xml', {}, credentials('jsmith')
538
567
539 assert_response :ok
568 assert_response :ok
540 assert_equal '', response.body
569 assert_equal '', response.body
541 end
570 end
542 assert_equal false, Issue.find(1).watched_by?(User.find(3))
571 assert_equal false, Issue.find(1).watched_by?(User.find(3))
543 end
572 end
544
573
545 def test_create_issue_with_uploaded_file
574 def test_create_issue_with_uploaded_file
546 token = xml_upload('test_create_with_upload', credentials('jsmith'))
575 token = xml_upload('test_create_with_upload', credentials('jsmith'))
547 attachment = Attachment.find_by_token(token)
576 attachment = Attachment.find_by_token(token)
548
577
549 # create the issue with the upload's token
578 # create the issue with the upload's token
550 assert_difference 'Issue.count' do
579 assert_difference 'Issue.count' do
551 post '/issues.xml',
580 post '/issues.xml',
552 {:issue => {:project_id => 1, :subject => 'Uploaded file',
581 {:issue => {:project_id => 1, :subject => 'Uploaded file',
553 :uploads => [{:token => token, :filename => 'test.txt',
582 :uploads => [{:token => token, :filename => 'test.txt',
554 :content_type => 'text/plain'}]}},
583 :content_type => 'text/plain'}]}},
555 credentials('jsmith')
584 credentials('jsmith')
556 assert_response :created
585 assert_response :created
557 end
586 end
558 issue = Issue.order('id DESC').first
587 issue = Issue.order('id DESC').first
559 assert_equal 1, issue.attachments.count
588 assert_equal 1, issue.attachments.count
560 assert_equal attachment, issue.attachments.first
589 assert_equal attachment, issue.attachments.first
561
590
562 attachment.reload
591 attachment.reload
563 assert_equal 'test.txt', attachment.filename
592 assert_equal 'test.txt', attachment.filename
564 assert_equal 'text/plain', attachment.content_type
593 assert_equal 'text/plain', attachment.content_type
565 assert_equal 'test_create_with_upload'.size, attachment.filesize
594 assert_equal 'test_create_with_upload'.size, attachment.filesize
566 assert_equal 2, attachment.author_id
595 assert_equal 2, attachment.author_id
567
596
568 # get the issue with its attachments
597 # get the issue with its attachments
569 get "/issues/#{issue.id}.xml", :include => 'attachments'
598 get "/issues/#{issue.id}.xml", :include => 'attachments'
570 assert_response :success
599 assert_response :success
571 xml = Hash.from_xml(response.body)
600 xml = Hash.from_xml(response.body)
572 attachments = xml['issue']['attachments']
601 attachments = xml['issue']['attachments']
573 assert_kind_of Array, attachments
602 assert_kind_of Array, attachments
574 assert_equal 1, attachments.size
603 assert_equal 1, attachments.size
575 url = attachments.first['content_url']
604 url = attachments.first['content_url']
576 assert_not_nil url
605 assert_not_nil url
577
606
578 # download the attachment
607 # download the attachment
579 get url
608 get url
580 assert_response :success
609 assert_response :success
581 assert_equal 'test_create_with_upload', response.body
610 assert_equal 'test_create_with_upload', response.body
582 end
611 end
583
612
584 def test_create_issue_with_multiple_uploaded_files_as_xml
613 def test_create_issue_with_multiple_uploaded_files_as_xml
585 token1 = xml_upload('File content 1', credentials('jsmith'))
614 token1 = xml_upload('File content 1', credentials('jsmith'))
586 token2 = xml_upload('File content 2', credentials('jsmith'))
615 token2 = xml_upload('File content 2', credentials('jsmith'))
587
616
588 payload = <<-XML
617 payload = <<-XML
589 <?xml version="1.0" encoding="UTF-8" ?>
618 <?xml version="1.0" encoding="UTF-8" ?>
590 <issue>
619 <issue>
591 <project_id>1</project_id>
620 <project_id>1</project_id>
592 <tracker_id>1</tracker_id>
621 <tracker_id>1</tracker_id>
593 <subject>Issue with multiple attachments</subject>
622 <subject>Issue with multiple attachments</subject>
594 <uploads type="array">
623 <uploads type="array">
595 <upload>
624 <upload>
596 <token>#{token1}</token>
625 <token>#{token1}</token>
597 <filename>test1.txt</filename>
626 <filename>test1.txt</filename>
598 </upload>
627 </upload>
599 <upload>
628 <upload>
600 <token>#{token2}</token>
629 <token>#{token2}</token>
601 <filename>test1.txt</filename>
630 <filename>test1.txt</filename>
602 </upload>
631 </upload>
603 </uploads>
632 </uploads>
604 </issue>
633 </issue>
605 XML
634 XML
606
635
607 assert_difference 'Issue.count' do
636 assert_difference 'Issue.count' do
608 post '/issues.xml', payload, {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith'))
637 post '/issues.xml', payload, {"CONTENT_TYPE" => 'application/xml'}.merge(credentials('jsmith'))
609 assert_response :created
638 assert_response :created
610 end
639 end
611 issue = Issue.order('id DESC').first
640 issue = Issue.order('id DESC').first
612 assert_equal 2, issue.attachments.count
641 assert_equal 2, issue.attachments.count
613 end
642 end
614
643
615 def test_create_issue_with_multiple_uploaded_files_as_json
644 def test_create_issue_with_multiple_uploaded_files_as_json
616 token1 = json_upload('File content 1', credentials('jsmith'))
645 token1 = json_upload('File content 1', credentials('jsmith'))
617 token2 = json_upload('File content 2', credentials('jsmith'))
646 token2 = json_upload('File content 2', credentials('jsmith'))
618
647
619 payload = <<-JSON
648 payload = <<-JSON
620 {
649 {
621 "issue": {
650 "issue": {
622 "project_id": "1",
651 "project_id": "1",
623 "tracker_id": "1",
652 "tracker_id": "1",
624 "subject": "Issue with multiple attachments",
653 "subject": "Issue with multiple attachments",
625 "uploads": [
654 "uploads": [
626 {"token": "#{token1}", "filename": "test1.txt"},
655 {"token": "#{token1}", "filename": "test1.txt"},
627 {"token": "#{token2}", "filename": "test2.txt"}
656 {"token": "#{token2}", "filename": "test2.txt"}
628 ]
657 ]
629 }
658 }
630 }
659 }
631 JSON
660 JSON
632
661
633 assert_difference 'Issue.count' do
662 assert_difference 'Issue.count' do
634 post '/issues.json', payload, {"CONTENT_TYPE" => 'application/json'}.merge(credentials('jsmith'))
663 post '/issues.json', payload, {"CONTENT_TYPE" => 'application/json'}.merge(credentials('jsmith'))
635 assert_response :created
664 assert_response :created
636 end
665 end
637 issue = Issue.order('id DESC').first
666 issue = Issue.order('id DESC').first
638 assert_equal 2, issue.attachments.count
667 assert_equal 2, issue.attachments.count
639 end
668 end
640
669
641 def test_update_issue_with_uploaded_file
670 def test_update_issue_with_uploaded_file
642 token = xml_upload('test_upload_with_upload', credentials('jsmith'))
671 token = xml_upload('test_upload_with_upload', credentials('jsmith'))
643 attachment = Attachment.find_by_token(token)
672 attachment = Attachment.find_by_token(token)
644
673
645 # update the issue with the upload's token
674 # update the issue with the upload's token
646 assert_difference 'Journal.count' do
675 assert_difference 'Journal.count' do
647 put '/issues/1.xml',
676 put '/issues/1.xml',
648 {:issue => {:notes => 'Attachment added',
677 {:issue => {:notes => 'Attachment added',
649 :uploads => [{:token => token, :filename => 'test.txt',
678 :uploads => [{:token => token, :filename => 'test.txt',
650 :content_type => 'text/plain'}]}},
679 :content_type => 'text/plain'}]}},
651 credentials('jsmith')
680 credentials('jsmith')
652 assert_response :ok
681 assert_response :ok
653 assert_equal '', @response.body
682 assert_equal '', @response.body
654 end
683 end
655
684
656 issue = Issue.find(1)
685 issue = Issue.find(1)
657 assert_include attachment, issue.attachments
686 assert_include attachment, issue.attachments
658 end
687 end
659 end
688 end
General Comments 0
You need to be logged in to leave comments. Login now