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