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