##// END OF EJS Templates
Fixes behaviour of move_issues permission for non member role (#5309)....
Jean-Philippe Lang -
r3569:0004b526464f
parent child
Show More
@@ -1,587 +1,580
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class IssuesController < ApplicationController
18 class IssuesController < ApplicationController
19 menu_item :new_issue, :only => :new
19 menu_item :new_issue, :only => :new
20 default_search_scope :issues
20 default_search_scope :issues
21
21
22 before_filter :find_issue, :only => [:show, :edit, :update, :reply]
22 before_filter :find_issue, :only => [:show, :edit, :update, :reply]
23 before_filter :find_issues, :only => [:bulk_edit, :move, :destroy]
23 before_filter :find_issues, :only => [:bulk_edit, :move, :destroy]
24 before_filter :find_project, :only => [:new, :update_form, :preview, :auto_complete]
24 before_filter :find_project, :only => [:new, :update_form, :preview, :auto_complete]
25 before_filter :authorize, :except => [:index, :changes, :gantt, :calendar, :preview, :context_menu]
25 before_filter :authorize, :except => [:index, :changes, :gantt, :calendar, :preview, :context_menu]
26 before_filter :find_optional_project, :only => [:index, :changes, :gantt, :calendar]
26 before_filter :find_optional_project, :only => [:index, :changes, :gantt, :calendar]
27 accept_key_auth :index, :show, :changes
27 accept_key_auth :index, :show, :changes
28
28
29 rescue_from Query::StatementInvalid, :with => :query_statement_invalid
29 rescue_from Query::StatementInvalid, :with => :query_statement_invalid
30
30
31 helper :journals
31 helper :journals
32 helper :projects
32 helper :projects
33 include ProjectsHelper
33 include ProjectsHelper
34 helper :custom_fields
34 helper :custom_fields
35 include CustomFieldsHelper
35 include CustomFieldsHelper
36 helper :issue_relations
36 helper :issue_relations
37 include IssueRelationsHelper
37 include IssueRelationsHelper
38 helper :watchers
38 helper :watchers
39 include WatchersHelper
39 include WatchersHelper
40 helper :attachments
40 helper :attachments
41 include AttachmentsHelper
41 include AttachmentsHelper
42 helper :queries
42 helper :queries
43 include QueriesHelper
43 include QueriesHelper
44 helper :sort
44 helper :sort
45 include SortHelper
45 include SortHelper
46 include IssuesHelper
46 include IssuesHelper
47 helper :timelog
47 helper :timelog
48 include Redmine::Export::PDF
48 include Redmine::Export::PDF
49
49
50 verify :method => [:post, :delete],
50 verify :method => [:post, :delete],
51 :only => :destroy,
51 :only => :destroy,
52 :render => { :nothing => true, :status => :method_not_allowed }
52 :render => { :nothing => true, :status => :method_not_allowed }
53
53
54 verify :method => :put, :only => :update, :render => {:nothing => true, :status => :method_not_allowed }
54 verify :method => :put, :only => :update, :render => {:nothing => true, :status => :method_not_allowed }
55
55
56 def index
56 def index
57 retrieve_query
57 retrieve_query
58 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
58 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
59 sort_update(@query.sortable_columns)
59 sort_update(@query.sortable_columns)
60
60
61 if @query.valid?
61 if @query.valid?
62 limit = case params[:format]
62 limit = case params[:format]
63 when 'csv', 'pdf'
63 when 'csv', 'pdf'
64 Setting.issues_export_limit.to_i
64 Setting.issues_export_limit.to_i
65 when 'atom'
65 when 'atom'
66 Setting.feeds_limit.to_i
66 Setting.feeds_limit.to_i
67 else
67 else
68 per_page_option
68 per_page_option
69 end
69 end
70
70
71 @issue_count = @query.issue_count
71 @issue_count = @query.issue_count
72 @issue_pages = Paginator.new self, @issue_count, limit, params['page']
72 @issue_pages = Paginator.new self, @issue_count, limit, params['page']
73 @issues = @query.issues(:include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
73 @issues = @query.issues(:include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
74 :order => sort_clause,
74 :order => sort_clause,
75 :offset => @issue_pages.current.offset,
75 :offset => @issue_pages.current.offset,
76 :limit => limit)
76 :limit => limit)
77 @issue_count_by_group = @query.issue_count_by_group
77 @issue_count_by_group = @query.issue_count_by_group
78
78
79 respond_to do |format|
79 respond_to do |format|
80 format.html { render :template => 'issues/index.rhtml', :layout => !request.xhr? }
80 format.html { render :template => 'issues/index.rhtml', :layout => !request.xhr? }
81 format.xml { render :layout => false }
81 format.xml { render :layout => false }
82 format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
82 format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
83 format.csv { send_data(issues_to_csv(@issues, @project), :type => 'text/csv; header=present', :filename => 'export.csv') }
83 format.csv { send_data(issues_to_csv(@issues, @project), :type => 'text/csv; header=present', :filename => 'export.csv') }
84 format.pdf { send_data(issues_to_pdf(@issues, @project, @query), :type => 'application/pdf', :filename => 'export.pdf') }
84 format.pdf { send_data(issues_to_pdf(@issues, @project, @query), :type => 'application/pdf', :filename => 'export.pdf') }
85 end
85 end
86 else
86 else
87 # Send html if the query is not valid
87 # Send html if the query is not valid
88 render(:template => 'issues/index.rhtml', :layout => !request.xhr?)
88 render(:template => 'issues/index.rhtml', :layout => !request.xhr?)
89 end
89 end
90 rescue ActiveRecord::RecordNotFound
90 rescue ActiveRecord::RecordNotFound
91 render_404
91 render_404
92 end
92 end
93
93
94 def changes
94 def changes
95 retrieve_query
95 retrieve_query
96 sort_init 'id', 'desc'
96 sort_init 'id', 'desc'
97 sort_update(@query.sortable_columns)
97 sort_update(@query.sortable_columns)
98
98
99 if @query.valid?
99 if @query.valid?
100 @journals = @query.journals(:order => "#{Journal.table_name}.created_on DESC",
100 @journals = @query.journals(:order => "#{Journal.table_name}.created_on DESC",
101 :limit => 25)
101 :limit => 25)
102 end
102 end
103 @title = (@project ? @project.name : Setting.app_title) + ": " + (@query.new_record? ? l(:label_changes_details) : @query.name)
103 @title = (@project ? @project.name : Setting.app_title) + ": " + (@query.new_record? ? l(:label_changes_details) : @query.name)
104 render :layout => false, :content_type => 'application/atom+xml'
104 render :layout => false, :content_type => 'application/atom+xml'
105 rescue ActiveRecord::RecordNotFound
105 rescue ActiveRecord::RecordNotFound
106 render_404
106 render_404
107 end
107 end
108
108
109 def show
109 def show
110 @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
110 @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
111 @journals.each_with_index {|j,i| j.indice = i+1}
111 @journals.each_with_index {|j,i| j.indice = i+1}
112 @journals.reverse! if User.current.wants_comments_in_reverse_order?
112 @journals.reverse! if User.current.wants_comments_in_reverse_order?
113 @changesets = @issue.changesets.visible.all
113 @changesets = @issue.changesets.visible.all
114 @changesets.reverse! if User.current.wants_comments_in_reverse_order?
114 @changesets.reverse! if User.current.wants_comments_in_reverse_order?
115 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
115 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
116 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
116 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
117 @priorities = IssuePriority.all
117 @priorities = IssuePriority.all
118 @time_entry = TimeEntry.new
118 @time_entry = TimeEntry.new
119 respond_to do |format|
119 respond_to do |format|
120 format.html { render :template => 'issues/show.rhtml' }
120 format.html { render :template => 'issues/show.rhtml' }
121 format.xml { render :layout => false }
121 format.xml { render :layout => false }
122 format.atom { render :action => 'changes', :layout => false, :content_type => 'application/atom+xml' }
122 format.atom { render :action => 'changes', :layout => false, :content_type => 'application/atom+xml' }
123 format.pdf { send_data(issue_to_pdf(@issue), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
123 format.pdf { send_data(issue_to_pdf(@issue), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
124 end
124 end
125 end
125 end
126
126
127 # Add a new issue
127 # Add a new issue
128 # The new issue will be created from an existing one if copy_from parameter is given
128 # The new issue will be created from an existing one if copy_from parameter is given
129 def new
129 def new
130 @issue = Issue.new
130 @issue = Issue.new
131 @issue.copy_from(params[:copy_from]) if params[:copy_from]
131 @issue.copy_from(params[:copy_from]) if params[:copy_from]
132 @issue.project = @project
132 @issue.project = @project
133 # Tracker must be set before custom field values
133 # Tracker must be set before custom field values
134 @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
134 @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
135 if @issue.tracker.nil?
135 if @issue.tracker.nil?
136 render_error l(:error_no_tracker_in_project)
136 render_error l(:error_no_tracker_in_project)
137 return
137 return
138 end
138 end
139 if @issue.status.nil?
139 if @issue.status.nil?
140 render_error l(:error_no_default_issue_status)
140 render_error l(:error_no_default_issue_status)
141 return
141 return
142 end
142 end
143 if params[:issue].is_a?(Hash)
143 if params[:issue].is_a?(Hash)
144 @issue.safe_attributes = params[:issue]
144 @issue.safe_attributes = params[:issue]
145 @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
145 @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
146 end
146 end
147 @issue.author = User.current
147 @issue.author = User.current
148
148
149 if request.get? || request.xhr?
149 if request.get? || request.xhr?
150 @issue.start_date ||= Date.today
150 @issue.start_date ||= Date.today
151 else
151 else
152 call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
152 call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
153 if @issue.save
153 if @issue.save
154 attachments = Attachment.attach_files(@issue, params[:attachments])
154 attachments = Attachment.attach_files(@issue, params[:attachments])
155 render_attachment_warning_if_needed(@issue)
155 render_attachment_warning_if_needed(@issue)
156 flash[:notice] = l(:notice_successful_create)
156 flash[:notice] = l(:notice_successful_create)
157 call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
157 call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
158 respond_to do |format|
158 respond_to do |format|
159 format.html {
159 format.html {
160 redirect_to(params[:continue] ? { :action => 'new', :issue => {:tracker_id => @issue.tracker,
160 redirect_to(params[:continue] ? { :action => 'new', :issue => {:tracker_id => @issue.tracker,
161 :parent_issue_id => @issue.parent_issue_id}.reject {|k,v| v.nil?} } :
161 :parent_issue_id => @issue.parent_issue_id}.reject {|k,v| v.nil?} } :
162 { :action => 'show', :id => @issue })
162 { :action => 'show', :id => @issue })
163 }
163 }
164 format.xml { render :action => 'show', :status => :created, :location => url_for(:controller => 'issues', :action => 'show', :id => @issue) }
164 format.xml { render :action => 'show', :status => :created, :location => url_for(:controller => 'issues', :action => 'show', :id => @issue) }
165 end
165 end
166 return
166 return
167 else
167 else
168 respond_to do |format|
168 respond_to do |format|
169 format.html { }
169 format.html { }
170 format.xml { render(:xml => @issue.errors, :status => :unprocessable_entity); return }
170 format.xml { render(:xml => @issue.errors, :status => :unprocessable_entity); return }
171 end
171 end
172 end
172 end
173 end
173 end
174 @priorities = IssuePriority.all
174 @priorities = IssuePriority.all
175 @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)
175 @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)
176 render :layout => !request.xhr?
176 render :layout => !request.xhr?
177 end
177 end
178
178
179 # Attributes that can be updated on workflow transition (without :edit permission)
179 # Attributes that can be updated on workflow transition (without :edit permission)
180 # TODO: make it configurable (at least per role)
180 # TODO: make it configurable (at least per role)
181 UPDATABLE_ATTRS_ON_TRANSITION = %w(status_id assigned_to_id fixed_version_id done_ratio) unless const_defined?(:UPDATABLE_ATTRS_ON_TRANSITION)
181 UPDATABLE_ATTRS_ON_TRANSITION = %w(status_id assigned_to_id fixed_version_id done_ratio) unless const_defined?(:UPDATABLE_ATTRS_ON_TRANSITION)
182
182
183 def edit
183 def edit
184 update_issue_from_params
184 update_issue_from_params
185
185
186 @journal = @issue.current_journal
186 @journal = @issue.current_journal
187
187
188 respond_to do |format|
188 respond_to do |format|
189 format.html { }
189 format.html { }
190 format.xml { }
190 format.xml { }
191 end
191 end
192 end
192 end
193
193
194 def update
194 def update
195 update_issue_from_params
195 update_issue_from_params
196
196
197 if @issue.save_issue_with_child_records(params, @time_entry)
197 if @issue.save_issue_with_child_records(params, @time_entry)
198 render_attachment_warning_if_needed(@issue)
198 render_attachment_warning_if_needed(@issue)
199 flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
199 flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
200
200
201 respond_to do |format|
201 respond_to do |format|
202 format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
202 format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
203 format.xml { head :ok }
203 format.xml { head :ok }
204 end
204 end
205 else
205 else
206 render_attachment_warning_if_needed(@issue)
206 render_attachment_warning_if_needed(@issue)
207 flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
207 flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
208 @journal = @issue.current_journal
208 @journal = @issue.current_journal
209
209
210 respond_to do |format|
210 respond_to do |format|
211 format.html { render :action => 'edit' }
211 format.html { render :action => 'edit' }
212 format.xml { render :xml => @issue.errors, :status => :unprocessable_entity }
212 format.xml { render :xml => @issue.errors, :status => :unprocessable_entity }
213 end
213 end
214 end
214 end
215 end
215 end
216
216
217 def reply
217 def reply
218 journal = Journal.find(params[:journal_id]) if params[:journal_id]
218 journal = Journal.find(params[:journal_id]) if params[:journal_id]
219 if journal
219 if journal
220 user = journal.user
220 user = journal.user
221 text = journal.notes
221 text = journal.notes
222 else
222 else
223 user = @issue.author
223 user = @issue.author
224 text = @issue.description
224 text = @issue.description
225 end
225 end
226 # Replaces pre blocks with [...]
226 # Replaces pre blocks with [...]
227 text = text.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]')
227 text = text.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]')
228 content = "#{ll(Setting.default_language, :text_user_wrote, user)}\n> "
228 content = "#{ll(Setting.default_language, :text_user_wrote, user)}\n> "
229 content << text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
229 content << text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
230
230
231 render(:update) { |page|
231 render(:update) { |page|
232 page.<< "$('notes').value = \"#{escape_javascript content}\";"
232 page.<< "$('notes').value = \"#{escape_javascript content}\";"
233 page.show 'update'
233 page.show 'update'
234 page << "Form.Element.focus('notes');"
234 page << "Form.Element.focus('notes');"
235 page << "Element.scrollTo('update');"
235 page << "Element.scrollTo('update');"
236 page << "$('notes').scrollTop = $('notes').scrollHeight - $('notes').clientHeight;"
236 page << "$('notes').scrollTop = $('notes').scrollHeight - $('notes').clientHeight;"
237 }
237 }
238 end
238 end
239
239
240 # Bulk edit a set of issues
240 # Bulk edit a set of issues
241 def bulk_edit
241 def bulk_edit
242 @issues.sort!
242 @issues.sort!
243 if request.post?
243 if request.post?
244 attributes = (params[:issue] || {}).reject {|k,v| v.blank?}
244 attributes = (params[:issue] || {}).reject {|k,v| v.blank?}
245 attributes.keys.each {|k| attributes[k] = '' if attributes[k] == 'none'}
245 attributes.keys.each {|k| attributes[k] = '' if attributes[k] == 'none'}
246 attributes[:custom_field_values].reject! {|k,v| v.blank?} if attributes[:custom_field_values]
246 attributes[:custom_field_values].reject! {|k,v| v.blank?} if attributes[:custom_field_values]
247
247
248 unsaved_issue_ids = []
248 unsaved_issue_ids = []
249 @issues.each do |issue|
249 @issues.each do |issue|
250 issue.reload
250 issue.reload
251 journal = issue.init_journal(User.current, params[:notes])
251 journal = issue.init_journal(User.current, params[:notes])
252 issue.safe_attributes = attributes
252 issue.safe_attributes = attributes
253 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
253 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
254 unless issue.save
254 unless issue.save
255 # Keep unsaved issue ids to display them in flash error
255 # Keep unsaved issue ids to display them in flash error
256 unsaved_issue_ids << issue.id
256 unsaved_issue_ids << issue.id
257 end
257 end
258 end
258 end
259 set_flash_from_bulk_issue_save(@issues, unsaved_issue_ids)
259 set_flash_from_bulk_issue_save(@issues, unsaved_issue_ids)
260 redirect_back_or_default({:controller => 'issues', :action => 'index', :project_id => @project})
260 redirect_back_or_default({:controller => 'issues', :action => 'index', :project_id => @project})
261 return
261 return
262 end
262 end
263 @available_statuses = Workflow.available_statuses(@project)
263 @available_statuses = Workflow.available_statuses(@project)
264 @custom_fields = @project.all_issue_custom_fields
264 @custom_fields = @project.all_issue_custom_fields
265 end
265 end
266
266
267 def move
267 def move
268 @issues.sort!
268 @issues.sort!
269 @copy = params[:copy_options] && params[:copy_options][:copy]
269 @copy = params[:copy_options] && params[:copy_options][:copy]
270 @allowed_projects = []
270 @allowed_projects = Issue.allowed_target_projects_on_move
271 # find projects to which the user is allowed to move the issue
272 if User.current.admin?
273 # admin is allowed to move issues to any active (visible) project
274 @allowed_projects = Project.find(:all, :conditions => Project.visible_by(User.current))
275 else
276 User.current.memberships.each {|m| @allowed_projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
277 end
278 @target_project = @allowed_projects.detect {|p| p.id.to_s == params[:new_project_id]} if params[:new_project_id]
271 @target_project = @allowed_projects.detect {|p| p.id.to_s == params[:new_project_id]} if params[:new_project_id]
279 @target_project ||= @project
272 @target_project ||= @project
280 @trackers = @target_project.trackers
273 @trackers = @target_project.trackers
281 @available_statuses = Workflow.available_statuses(@project)
274 @available_statuses = Workflow.available_statuses(@project)
282 if request.post?
275 if request.post?
283 new_tracker = params[:new_tracker_id].blank? ? nil : @target_project.trackers.find_by_id(params[:new_tracker_id])
276 new_tracker = params[:new_tracker_id].blank? ? nil : @target_project.trackers.find_by_id(params[:new_tracker_id])
284 unsaved_issue_ids = []
277 unsaved_issue_ids = []
285 moved_issues = []
278 moved_issues = []
286 @issues.each do |issue|
279 @issues.each do |issue|
287 issue.reload
280 issue.reload
288 changed_attributes = {}
281 changed_attributes = {}
289 [:assigned_to_id, :status_id, :start_date, :due_date].each do |valid_attribute|
282 [:assigned_to_id, :status_id, :start_date, :due_date].each do |valid_attribute|
290 unless params[valid_attribute].blank?
283 unless params[valid_attribute].blank?
291 changed_attributes[valid_attribute] = (params[valid_attribute] == 'none' ? nil : params[valid_attribute])
284 changed_attributes[valid_attribute] = (params[valid_attribute] == 'none' ? nil : params[valid_attribute])
292 end
285 end
293 end
286 end
294 issue.init_journal(User.current)
287 issue.init_journal(User.current)
295 call_hook(:controller_issues_move_before_save, { :params => params, :issue => issue, :target_project => @target_project, :copy => !!@copy })
288 call_hook(:controller_issues_move_before_save, { :params => params, :issue => issue, :target_project => @target_project, :copy => !!@copy })
296 if r = issue.move_to_project(@target_project, new_tracker, {:copy => @copy, :attributes => changed_attributes})
289 if r = issue.move_to_project(@target_project, new_tracker, {:copy => @copy, :attributes => changed_attributes})
297 moved_issues << r
290 moved_issues << r
298 else
291 else
299 unsaved_issue_ids << issue.id
292 unsaved_issue_ids << issue.id
300 end
293 end
301 end
294 end
302 set_flash_from_bulk_issue_save(@issues, unsaved_issue_ids)
295 set_flash_from_bulk_issue_save(@issues, unsaved_issue_ids)
303
296
304 if params[:follow]
297 if params[:follow]
305 if @issues.size == 1 && moved_issues.size == 1
298 if @issues.size == 1 && moved_issues.size == 1
306 redirect_to :controller => 'issues', :action => 'show', :id => moved_issues.first
299 redirect_to :controller => 'issues', :action => 'show', :id => moved_issues.first
307 else
300 else
308 redirect_to :controller => 'issues', :action => 'index', :project_id => (@target_project || @project)
301 redirect_to :controller => 'issues', :action => 'index', :project_id => (@target_project || @project)
309 end
302 end
310 else
303 else
311 redirect_to :controller => 'issues', :action => 'index', :project_id => @project
304 redirect_to :controller => 'issues', :action => 'index', :project_id => @project
312 end
305 end
313 return
306 return
314 end
307 end
315 render :layout => false if request.xhr?
308 render :layout => false if request.xhr?
316 end
309 end
317
310
318 def destroy
311 def destroy
319 @hours = TimeEntry.sum(:hours, :conditions => ['issue_id IN (?)', @issues]).to_f
312 @hours = TimeEntry.sum(:hours, :conditions => ['issue_id IN (?)', @issues]).to_f
320 if @hours > 0
313 if @hours > 0
321 case params[:todo]
314 case params[:todo]
322 when 'destroy'
315 when 'destroy'
323 # nothing to do
316 # nothing to do
324 when 'nullify'
317 when 'nullify'
325 TimeEntry.update_all('issue_id = NULL', ['issue_id IN (?)', @issues])
318 TimeEntry.update_all('issue_id = NULL', ['issue_id IN (?)', @issues])
326 when 'reassign'
319 when 'reassign'
327 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
320 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
328 if reassign_to.nil?
321 if reassign_to.nil?
329 flash.now[:error] = l(:error_issue_not_found_in_project)
322 flash.now[:error] = l(:error_issue_not_found_in_project)
330 return
323 return
331 else
324 else
332 TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
325 TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
333 end
326 end
334 else
327 else
335 unless params[:format] == 'xml'
328 unless params[:format] == 'xml'
336 # display the destroy form if it's a user request
329 # display the destroy form if it's a user request
337 return
330 return
338 end
331 end
339 end
332 end
340 end
333 end
341 @issues.each(&:destroy)
334 @issues.each(&:destroy)
342 respond_to do |format|
335 respond_to do |format|
343 format.html { redirect_to :action => 'index', :project_id => @project }
336 format.html { redirect_to :action => 'index', :project_id => @project }
344 format.xml { head :ok }
337 format.xml { head :ok }
345 end
338 end
346 end
339 end
347
340
348 def gantt
341 def gantt
349 @gantt = Redmine::Helpers::Gantt.new(params)
342 @gantt = Redmine::Helpers::Gantt.new(params)
350 retrieve_query
343 retrieve_query
351 @query.group_by = nil
344 @query.group_by = nil
352 if @query.valid?
345 if @query.valid?
353 events = []
346 events = []
354 # Issues that have start and due dates
347 # Issues that have start and due dates
355 events += @query.issues(:include => [:tracker, :assigned_to, :priority],
348 events += @query.issues(:include => [:tracker, :assigned_to, :priority],
356 :order => "start_date, due_date",
349 :order => "start_date, due_date",
357 :conditions => ["(((start_date>=? and start_date<=?) or (due_date>=? and due_date<=?) or (start_date<? and due_date>?)) and start_date is not null and due_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
350 :conditions => ["(((start_date>=? and start_date<=?) or (due_date>=? and due_date<=?) or (start_date<? and due_date>?)) and start_date is not null and due_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
358 )
351 )
359 # Issues that don't have a due date but that are assigned to a version with a date
352 # Issues that don't have a due date but that are assigned to a version with a date
360 events += @query.issues(:include => [:tracker, :assigned_to, :priority, :fixed_version],
353 events += @query.issues(:include => [:tracker, :assigned_to, :priority, :fixed_version],
361 :order => "start_date, effective_date",
354 :order => "start_date, effective_date",
362 :conditions => ["(((start_date>=? and start_date<=?) or (effective_date>=? and effective_date<=?) or (start_date<? and effective_date>?)) and start_date is not null and due_date is null and effective_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
355 :conditions => ["(((start_date>=? and start_date<=?) or (effective_date>=? and effective_date<=?) or (start_date<? and effective_date>?)) and start_date is not null and due_date is null and effective_date is not null)", @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to, @gantt.date_from, @gantt.date_to]
363 )
356 )
364 # Versions
357 # Versions
365 events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @gantt.date_from, @gantt.date_to])
358 events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @gantt.date_from, @gantt.date_to])
366
359
367 @gantt.events = events
360 @gantt.events = events
368 end
361 end
369
362
370 basename = (@project ? "#{@project.identifier}-" : '') + 'gantt'
363 basename = (@project ? "#{@project.identifier}-" : '') + 'gantt'
371
364
372 respond_to do |format|
365 respond_to do |format|
373 format.html { render :template => "issues/gantt.rhtml", :layout => !request.xhr? }
366 format.html { render :template => "issues/gantt.rhtml", :layout => !request.xhr? }
374 format.png { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
367 format.png { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
375 format.pdf { send_data(gantt_to_pdf(@gantt, @project), :type => 'application/pdf', :filename => "#{basename}.pdf") }
368 format.pdf { send_data(gantt_to_pdf(@gantt, @project), :type => 'application/pdf', :filename => "#{basename}.pdf") }
376 end
369 end
377 end
370 end
378
371
379 def calendar
372 def calendar
380 if params[:year] and params[:year].to_i > 1900
373 if params[:year] and params[:year].to_i > 1900
381 @year = params[:year].to_i
374 @year = params[:year].to_i
382 if params[:month] and params[:month].to_i > 0 and params[:month].to_i < 13
375 if params[:month] and params[:month].to_i > 0 and params[:month].to_i < 13
383 @month = params[:month].to_i
376 @month = params[:month].to_i
384 end
377 end
385 end
378 end
386 @year ||= Date.today.year
379 @year ||= Date.today.year
387 @month ||= Date.today.month
380 @month ||= Date.today.month
388
381
389 @calendar = Redmine::Helpers::Calendar.new(Date.civil(@year, @month, 1), current_language, :month)
382 @calendar = Redmine::Helpers::Calendar.new(Date.civil(@year, @month, 1), current_language, :month)
390 retrieve_query
383 retrieve_query
391 @query.group_by = nil
384 @query.group_by = nil
392 if @query.valid?
385 if @query.valid?
393 events = []
386 events = []
394 events += @query.issues(:include => [:tracker, :assigned_to, :priority],
387 events += @query.issues(:include => [:tracker, :assigned_to, :priority],
395 :conditions => ["((start_date BETWEEN ? AND ?) OR (due_date BETWEEN ? AND ?))", @calendar.startdt, @calendar.enddt, @calendar.startdt, @calendar.enddt]
388 :conditions => ["((start_date BETWEEN ? AND ?) OR (due_date BETWEEN ? AND ?))", @calendar.startdt, @calendar.enddt, @calendar.startdt, @calendar.enddt]
396 )
389 )
397 events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @calendar.startdt, @calendar.enddt])
390 events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @calendar.startdt, @calendar.enddt])
398
391
399 @calendar.events = events
392 @calendar.events = events
400 end
393 end
401
394
402 render :layout => false if request.xhr?
395 render :layout => false if request.xhr?
403 end
396 end
404
397
405 def context_menu
398 def context_menu
406 @issues = Issue.find_all_by_id(params[:ids], :include => :project)
399 @issues = Issue.find_all_by_id(params[:ids], :include => :project)
407 if (@issues.size == 1)
400 if (@issues.size == 1)
408 @issue = @issues.first
401 @issue = @issues.first
409 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
402 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
410 end
403 end
411 projects = @issues.collect(&:project).compact.uniq
404 projects = @issues.collect(&:project).compact.uniq
412 @project = projects.first if projects.size == 1
405 @project = projects.first if projects.size == 1
413
406
414 @can = {:edit => (@project && User.current.allowed_to?(:edit_issues, @project)),
407 @can = {:edit => (@project && User.current.allowed_to?(:edit_issues, @project)),
415 :log_time => (@project && User.current.allowed_to?(:log_time, @project)),
408 :log_time => (@project && User.current.allowed_to?(:log_time, @project)),
416 :update => (@project && (User.current.allowed_to?(:edit_issues, @project) || (User.current.allowed_to?(:change_status, @project) && @allowed_statuses && !@allowed_statuses.empty?))),
409 :update => (@project && (User.current.allowed_to?(:edit_issues, @project) || (User.current.allowed_to?(:change_status, @project) && @allowed_statuses && !@allowed_statuses.empty?))),
417 :move => (@project && User.current.allowed_to?(:move_issues, @project)),
410 :move => (@project && User.current.allowed_to?(:move_issues, @project)),
418 :copy => (@issue && @project.trackers.include?(@issue.tracker) && User.current.allowed_to?(:add_issues, @project)),
411 :copy => (@issue && @project.trackers.include?(@issue.tracker) && User.current.allowed_to?(:add_issues, @project)),
419 :delete => (@project && User.current.allowed_to?(:delete_issues, @project))
412 :delete => (@project && User.current.allowed_to?(:delete_issues, @project))
420 }
413 }
421 if @project
414 if @project
422 @assignables = @project.assignable_users
415 @assignables = @project.assignable_users
423 @assignables << @issue.assigned_to if @issue && @issue.assigned_to && !@assignables.include?(@issue.assigned_to)
416 @assignables << @issue.assigned_to if @issue && @issue.assigned_to && !@assignables.include?(@issue.assigned_to)
424 @trackers = @project.trackers
417 @trackers = @project.trackers
425 end
418 end
426
419
427 @priorities = IssuePriority.all.reverse
420 @priorities = IssuePriority.all.reverse
428 @statuses = IssueStatus.find(:all, :order => 'position')
421 @statuses = IssueStatus.find(:all, :order => 'position')
429 @back = params[:back_url] || request.env['HTTP_REFERER']
422 @back = params[:back_url] || request.env['HTTP_REFERER']
430
423
431 render :layout => false
424 render :layout => false
432 end
425 end
433
426
434 def update_form
427 def update_form
435 if params[:id].blank?
428 if params[:id].blank?
436 @issue = Issue.new
429 @issue = Issue.new
437 @issue.project = @project
430 @issue.project = @project
438 else
431 else
439 @issue = @project.issues.visible.find(params[:id])
432 @issue = @project.issues.visible.find(params[:id])
440 end
433 end
441 @issue.attributes = params[:issue]
434 @issue.attributes = params[:issue]
442 @allowed_statuses = ([@issue.status] + @issue.status.find_new_statuses_allowed_to(User.current.roles_for_project(@project), @issue.tracker)).uniq
435 @allowed_statuses = ([@issue.status] + @issue.status.find_new_statuses_allowed_to(User.current.roles_for_project(@project), @issue.tracker)).uniq
443 @priorities = IssuePriority.all
436 @priorities = IssuePriority.all
444
437
445 render :partial => 'attributes'
438 render :partial => 'attributes'
446 end
439 end
447
440
448 def preview
441 def preview
449 @issue = @project.issues.find_by_id(params[:id]) unless params[:id].blank?
442 @issue = @project.issues.find_by_id(params[:id]) unless params[:id].blank?
450 if @issue
443 if @issue
451 @attachements = @issue.attachments
444 @attachements = @issue.attachments
452 @description = params[:issue] && params[:issue][:description]
445 @description = params[:issue] && params[:issue][:description]
453 if @description && @description.gsub(/(\r?\n|\n\r?)/, "\n") == @issue.description.to_s.gsub(/(\r?\n|\n\r?)/, "\n")
446 if @description && @description.gsub(/(\r?\n|\n\r?)/, "\n") == @issue.description.to_s.gsub(/(\r?\n|\n\r?)/, "\n")
454 @description = nil
447 @description = nil
455 end
448 end
456 @notes = params[:notes]
449 @notes = params[:notes]
457 else
450 else
458 @description = (params[:issue] ? params[:issue][:description] : nil)
451 @description = (params[:issue] ? params[:issue][:description] : nil)
459 end
452 end
460 render :layout => false
453 render :layout => false
461 end
454 end
462
455
463 def auto_complete
456 def auto_complete
464 @issues = []
457 @issues = []
465 q = params[:q].to_s
458 q = params[:q].to_s
466 if q.match(/^\d+$/)
459 if q.match(/^\d+$/)
467 @issues << @project.issues.visible.find_by_id(q.to_i)
460 @issues << @project.issues.visible.find_by_id(q.to_i)
468 end
461 end
469 unless q.blank?
462 unless q.blank?
470 @issues += @project.issues.visible.find(:all, :conditions => ["LOWER(#{Issue.table_name}.subject) LIKE ?", "%#{q.downcase}%"], :limit => 10)
463 @issues += @project.issues.visible.find(:all, :conditions => ["LOWER(#{Issue.table_name}.subject) LIKE ?", "%#{q.downcase}%"], :limit => 10)
471 end
464 end
472 render :layout => false
465 render :layout => false
473 end
466 end
474
467
475 private
468 private
476 def find_issue
469 def find_issue
477 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
470 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
478 @project = @issue.project
471 @project = @issue.project
479 rescue ActiveRecord::RecordNotFound
472 rescue ActiveRecord::RecordNotFound
480 render_404
473 render_404
481 end
474 end
482
475
483 # Filter for bulk operations
476 # Filter for bulk operations
484 def find_issues
477 def find_issues
485 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
478 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
486 raise ActiveRecord::RecordNotFound if @issues.empty?
479 raise ActiveRecord::RecordNotFound if @issues.empty?
487 projects = @issues.collect(&:project).compact.uniq
480 projects = @issues.collect(&:project).compact.uniq
488 if projects.size == 1
481 if projects.size == 1
489 @project = projects.first
482 @project = projects.first
490 else
483 else
491 # TODO: let users bulk edit/move/destroy issues from different projects
484 # TODO: let users bulk edit/move/destroy issues from different projects
492 render_error 'Can not bulk edit/move/destroy issues from different projects'
485 render_error 'Can not bulk edit/move/destroy issues from different projects'
493 return false
486 return false
494 end
487 end
495 rescue ActiveRecord::RecordNotFound
488 rescue ActiveRecord::RecordNotFound
496 render_404
489 render_404
497 end
490 end
498
491
499 def find_project
492 def find_project
500 project_id = (params[:issue] && params[:issue][:project_id]) || params[:project_id]
493 project_id = (params[:issue] && params[:issue][:project_id]) || params[:project_id]
501 @project = Project.find(project_id)
494 @project = Project.find(project_id)
502 rescue ActiveRecord::RecordNotFound
495 rescue ActiveRecord::RecordNotFound
503 render_404
496 render_404
504 end
497 end
505
498
506 def find_optional_project
499 def find_optional_project
507 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
500 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
508 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
501 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
509 allowed ? true : deny_access
502 allowed ? true : deny_access
510 rescue ActiveRecord::RecordNotFound
503 rescue ActiveRecord::RecordNotFound
511 render_404
504 render_404
512 end
505 end
513
506
514 # Retrieve query from session or build a new query
507 # Retrieve query from session or build a new query
515 def retrieve_query
508 def retrieve_query
516 if !params[:query_id].blank?
509 if !params[:query_id].blank?
517 cond = "project_id IS NULL"
510 cond = "project_id IS NULL"
518 cond << " OR project_id = #{@project.id}" if @project
511 cond << " OR project_id = #{@project.id}" if @project
519 @query = Query.find(params[:query_id], :conditions => cond)
512 @query = Query.find(params[:query_id], :conditions => cond)
520 @query.project = @project
513 @query.project = @project
521 session[:query] = {:id => @query.id, :project_id => @query.project_id}
514 session[:query] = {:id => @query.id, :project_id => @query.project_id}
522 sort_clear
515 sort_clear
523 else
516 else
524 if api_request? || params[:set_filter] || session[:query].nil? || session[:query][:project_id] != (@project ? @project.id : nil)
517 if api_request? || params[:set_filter] || session[:query].nil? || session[:query][:project_id] != (@project ? @project.id : nil)
525 # Give it a name, required to be valid
518 # Give it a name, required to be valid
526 @query = Query.new(:name => "_")
519 @query = Query.new(:name => "_")
527 @query.project = @project
520 @query.project = @project
528 if params[:fields] and params[:fields].is_a? Array
521 if params[:fields] and params[:fields].is_a? Array
529 params[:fields].each do |field|
522 params[:fields].each do |field|
530 @query.add_filter(field, params[:operators][field], params[:values][field])
523 @query.add_filter(field, params[:operators][field], params[:values][field])
531 end
524 end
532 else
525 else
533 @query.available_filters.keys.each do |field|
526 @query.available_filters.keys.each do |field|
534 @query.add_short_filter(field, params[field]) if params[field]
527 @query.add_short_filter(field, params[field]) if params[field]
535 end
528 end
536 end
529 end
537 @query.group_by = params[:group_by]
530 @query.group_by = params[:group_by]
538 @query.column_names = params[:query] && params[:query][:column_names]
531 @query.column_names = params[:query] && params[:query][:column_names]
539 session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names}
532 session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names}
540 else
533 else
541 @query = Query.find_by_id(session[:query][:id]) if session[:query][:id]
534 @query = Query.find_by_id(session[:query][:id]) if session[:query][:id]
542 @query ||= Query.new(:name => "_", :project => @project, :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names])
535 @query ||= Query.new(:name => "_", :project => @project, :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names])
543 @query.project = @project
536 @query.project = @project
544 end
537 end
545 end
538 end
546 end
539 end
547
540
548 # Rescues an invalid query statement. Just in case...
541 # Rescues an invalid query statement. Just in case...
549 def query_statement_invalid(exception)
542 def query_statement_invalid(exception)
550 logger.error "Query::StatementInvalid: #{exception.message}" if logger
543 logger.error "Query::StatementInvalid: #{exception.message}" if logger
551 session.delete(:query)
544 session.delete(:query)
552 sort_clear
545 sort_clear
553 render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
546 render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
554 end
547 end
555
548
556 # Used by #edit and #update to set some common instance variables
549 # Used by #edit and #update to set some common instance variables
557 # from the params
550 # from the params
558 # TODO: Refactor, not everything in here is needed by #edit
551 # TODO: Refactor, not everything in here is needed by #edit
559 def update_issue_from_params
552 def update_issue_from_params
560 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
553 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
561 @priorities = IssuePriority.all
554 @priorities = IssuePriority.all
562 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
555 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
563 @time_entry = TimeEntry.new
556 @time_entry = TimeEntry.new
564
557
565 @notes = params[:notes]
558 @notes = params[:notes]
566 @issue.init_journal(User.current, @notes)
559 @issue.init_journal(User.current, @notes)
567 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
560 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
568 if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
561 if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
569 attrs = params[:issue].dup
562 attrs = params[:issue].dup
570 attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
563 attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
571 attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
564 attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
572 @issue.safe_attributes = attrs
565 @issue.safe_attributes = attrs
573 end
566 end
574
567
575 end
568 end
576
569
577 def set_flash_from_bulk_issue_save(issues, unsaved_issue_ids)
570 def set_flash_from_bulk_issue_save(issues, unsaved_issue_ids)
578 if unsaved_issue_ids.empty?
571 if unsaved_issue_ids.empty?
579 flash[:notice] = l(:notice_successful_update) unless issues.empty?
572 flash[:notice] = l(:notice_successful_update) unless issues.empty?
580 else
573 else
581 flash[:error] = l(:notice_failed_to_save_issues,
574 flash[:error] = l(:notice_failed_to_save_issues,
582 :count => unsaved_issue_ids.size,
575 :count => unsaved_issue_ids.size,
583 :total => issues.size,
576 :total => issues.size,
584 :ids => '#' + unsaved_issue_ids.join(', #'))
577 :ids => '#' + unsaved_issue_ids.join(', #'))
585 end
578 end
586 end
579 end
587 end
580 end
@@ -1,810 +1,826
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 belongs_to :project
19 belongs_to :project
20 belongs_to :tracker
20 belongs_to :tracker
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
25 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27
27
28 has_many :journals, :as => :journalized, :dependent => :destroy
28 has_many :journals, :as => :journalized, :dependent => :destroy
29 has_many :time_entries, :dependent => :delete_all
29 has_many :time_entries, :dependent => :delete_all
30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
30 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
31
31
32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
32 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
33 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
34
34
35 acts_as_nested_set :scope => 'root_id'
35 acts_as_nested_set :scope => 'root_id'
36 acts_as_attachable :after_remove => :attachment_removed
36 acts_as_attachable :after_remove => :attachment_removed
37 acts_as_customizable
37 acts_as_customizable
38 acts_as_watchable
38 acts_as_watchable
39 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
39 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
40 :include => [:project, :journals],
40 :include => [:project, :journals],
41 # sort by id so that limited eager loading doesn't break with postgresql
41 # sort by id so that limited eager loading doesn't break with postgresql
42 :order_column => "#{table_name}.id"
42 :order_column => "#{table_name}.id"
43 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
43 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
44 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
44 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
45 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
45 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
46
46
47 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
47 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
48 :author_key => :author_id
48 :author_key => :author_id
49
49
50 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
50 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
51
51
52 attr_reader :current_journal
52 attr_reader :current_journal
53
53
54 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
54 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
55
55
56 validates_length_of :subject, :maximum => 255
56 validates_length_of :subject, :maximum => 255
57 validates_inclusion_of :done_ratio, :in => 0..100
57 validates_inclusion_of :done_ratio, :in => 0..100
58 validates_numericality_of :estimated_hours, :allow_nil => true
58 validates_numericality_of :estimated_hours, :allow_nil => true
59
59
60 named_scope :visible, lambda {|*args| { :include => :project,
60 named_scope :visible, lambda {|*args| { :include => :project,
61 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
61 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
62
62
63 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
63 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
64
64
65 named_scope :recently_updated, :order => "#{self.table_name}.updated_on DESC"
65 named_scope :recently_updated, :order => "#{self.table_name}.updated_on DESC"
66 named_scope :with_limit, lambda { |limit| { :limit => limit} }
66 named_scope :with_limit, lambda { |limit| { :limit => limit} }
67 named_scope :on_active_project, :include => [:status, :project, :tracker],
67 named_scope :on_active_project, :include => [:status, :project, :tracker],
68 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
68 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
69
69
70 before_create :default_assign
70 before_create :default_assign
71 before_save :reschedule_following_issues, :close_duplicates, :update_done_ratio_from_issue_status
71 before_save :reschedule_following_issues, :close_duplicates, :update_done_ratio_from_issue_status
72 after_save :update_nested_set_attributes, :update_parent_attributes, :create_journal
72 after_save :update_nested_set_attributes, :update_parent_attributes, :create_journal
73 after_destroy :destroy_children
73 after_destroy :destroy_children
74 after_destroy :update_parent_attributes
74 after_destroy :update_parent_attributes
75
75
76 # Returns true if usr or current user is allowed to view the issue
76 # Returns true if usr or current user is allowed to view the issue
77 def visible?(usr=nil)
77 def visible?(usr=nil)
78 (usr || User.current).allowed_to?(:view_issues, self.project)
78 (usr || User.current).allowed_to?(:view_issues, self.project)
79 end
79 end
80
80
81 def after_initialize
81 def after_initialize
82 if new_record?
82 if new_record?
83 # set default values for new records only
83 # set default values for new records only
84 self.status ||= IssueStatus.default
84 self.status ||= IssueStatus.default
85 self.priority ||= IssuePriority.default
85 self.priority ||= IssuePriority.default
86 end
86 end
87 end
87 end
88
88
89 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
89 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
90 def available_custom_fields
90 def available_custom_fields
91 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
91 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
92 end
92 end
93
93
94 def copy_from(arg)
94 def copy_from(arg)
95 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
95 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
96 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
96 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
97 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
97 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
98 self.status = issue.status
98 self.status = issue.status
99 self
99 self
100 end
100 end
101
101
102 # Moves/copies an issue to a new project and tracker
102 # Moves/copies an issue to a new project and tracker
103 # Returns the moved/copied issue on success, false on failure
103 # Returns the moved/copied issue on success, false on failure
104 def move_to_project(*args)
104 def move_to_project(*args)
105 ret = Issue.transaction do
105 ret = Issue.transaction do
106 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
106 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
107 end || false
107 end || false
108 end
108 end
109
109
110 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
110 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
111 options ||= {}
111 options ||= {}
112 issue = options[:copy] ? self.class.new.copy_from(self) : self
112 issue = options[:copy] ? self.class.new.copy_from(self) : self
113
113
114 if new_project && issue.project_id != new_project.id
114 if new_project && issue.project_id != new_project.id
115 # delete issue relations
115 # delete issue relations
116 unless Setting.cross_project_issue_relations?
116 unless Setting.cross_project_issue_relations?
117 issue.relations_from.clear
117 issue.relations_from.clear
118 issue.relations_to.clear
118 issue.relations_to.clear
119 end
119 end
120 # issue is moved to another project
120 # issue is moved to another project
121 # reassign to the category with same name if any
121 # reassign to the category with same name if any
122 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
122 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
123 issue.category = new_category
123 issue.category = new_category
124 # Keep the fixed_version if it's still valid in the new_project
124 # Keep the fixed_version if it's still valid in the new_project
125 unless new_project.shared_versions.include?(issue.fixed_version)
125 unless new_project.shared_versions.include?(issue.fixed_version)
126 issue.fixed_version = nil
126 issue.fixed_version = nil
127 end
127 end
128 issue.project = new_project
128 issue.project = new_project
129 if issue.parent && issue.parent.project_id != issue.project_id
129 if issue.parent && issue.parent.project_id != issue.project_id
130 issue.parent_issue_id = nil
130 issue.parent_issue_id = nil
131 end
131 end
132 end
132 end
133 if new_tracker
133 if new_tracker
134 issue.tracker = new_tracker
134 issue.tracker = new_tracker
135 issue.reset_custom_values!
135 issue.reset_custom_values!
136 end
136 end
137 if options[:copy]
137 if options[:copy]
138 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
138 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
139 issue.status = if options[:attributes] && options[:attributes][:status_id]
139 issue.status = if options[:attributes] && options[:attributes][:status_id]
140 IssueStatus.find_by_id(options[:attributes][:status_id])
140 IssueStatus.find_by_id(options[:attributes][:status_id])
141 else
141 else
142 self.status
142 self.status
143 end
143 end
144 end
144 end
145 # Allow bulk setting of attributes on the issue
145 # Allow bulk setting of attributes on the issue
146 if options[:attributes]
146 if options[:attributes]
147 issue.attributes = options[:attributes]
147 issue.attributes = options[:attributes]
148 end
148 end
149 if issue.save
149 if issue.save
150 unless options[:copy]
150 unless options[:copy]
151 # Manually update project_id on related time entries
151 # Manually update project_id on related time entries
152 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
152 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
153
153
154 issue.children.each do |child|
154 issue.children.each do |child|
155 unless child.move_to_project_without_transaction(new_project)
155 unless child.move_to_project_without_transaction(new_project)
156 # Move failed and transaction was rollback'd
156 # Move failed and transaction was rollback'd
157 return false
157 return false
158 end
158 end
159 end
159 end
160 end
160 end
161 else
161 else
162 return false
162 return false
163 end
163 end
164 issue
164 issue
165 end
165 end
166
166
167 def status_id=(sid)
167 def status_id=(sid)
168 self.status = nil
168 self.status = nil
169 write_attribute(:status_id, sid)
169 write_attribute(:status_id, sid)
170 end
170 end
171
171
172 def priority_id=(pid)
172 def priority_id=(pid)
173 self.priority = nil
173 self.priority = nil
174 write_attribute(:priority_id, pid)
174 write_attribute(:priority_id, pid)
175 end
175 end
176
176
177 def tracker_id=(tid)
177 def tracker_id=(tid)
178 self.tracker = nil
178 self.tracker = nil
179 result = write_attribute(:tracker_id, tid)
179 result = write_attribute(:tracker_id, tid)
180 @custom_field_values = nil
180 @custom_field_values = nil
181 result
181 result
182 end
182 end
183
183
184 # Overrides attributes= so that tracker_id gets assigned first
184 # Overrides attributes= so that tracker_id gets assigned first
185 def attributes_with_tracker_first=(new_attributes, *args)
185 def attributes_with_tracker_first=(new_attributes, *args)
186 return if new_attributes.nil?
186 return if new_attributes.nil?
187 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
187 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
188 if new_tracker_id
188 if new_tracker_id
189 self.tracker_id = new_tracker_id
189 self.tracker_id = new_tracker_id
190 end
190 end
191 send :attributes_without_tracker_first=, new_attributes, *args
191 send :attributes_without_tracker_first=, new_attributes, *args
192 end
192 end
193 # Do not redefine alias chain on reload (see #4838)
193 # Do not redefine alias chain on reload (see #4838)
194 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
194 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
195
195
196 def estimated_hours=(h)
196 def estimated_hours=(h)
197 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
197 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
198 end
198 end
199
199
200 SAFE_ATTRIBUTES = %w(
200 SAFE_ATTRIBUTES = %w(
201 tracker_id
201 tracker_id
202 status_id
202 status_id
203 parent_issue_id
203 parent_issue_id
204 category_id
204 category_id
205 assigned_to_id
205 assigned_to_id
206 priority_id
206 priority_id
207 fixed_version_id
207 fixed_version_id
208 subject
208 subject
209 description
209 description
210 start_date
210 start_date
211 due_date
211 due_date
212 done_ratio
212 done_ratio
213 estimated_hours
213 estimated_hours
214 custom_field_values
214 custom_field_values
215 lock_version
215 lock_version
216 ) unless const_defined?(:SAFE_ATTRIBUTES)
216 ) unless const_defined?(:SAFE_ATTRIBUTES)
217
217
218 # Safely sets attributes
218 # Safely sets attributes
219 # Should be called from controllers instead of #attributes=
219 # Should be called from controllers instead of #attributes=
220 # attr_accessible is too rough because we still want things like
220 # attr_accessible is too rough because we still want things like
221 # Issue.new(:project => foo) to work
221 # Issue.new(:project => foo) to work
222 # TODO: move workflow/permission checks from controllers to here
222 # TODO: move workflow/permission checks from controllers to here
223 def safe_attributes=(attrs, user=User.current)
223 def safe_attributes=(attrs, user=User.current)
224 return if attrs.nil?
224 return if attrs.nil?
225 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
225 attrs = attrs.reject {|k,v| !SAFE_ATTRIBUTES.include?(k)}
226 if attrs['status_id']
226 if attrs['status_id']
227 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
227 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
228 attrs.delete('status_id')
228 attrs.delete('status_id')
229 end
229 end
230 end
230 end
231
231
232 unless leaf?
232 unless leaf?
233 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
233 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
234 end
234 end
235
235
236 if attrs.has_key?('parent_issue_id')
236 if attrs.has_key?('parent_issue_id')
237 if !user.allowed_to?(:manage_subtasks, project)
237 if !user.allowed_to?(:manage_subtasks, project)
238 attrs.delete('parent_issue_id')
238 attrs.delete('parent_issue_id')
239 elsif !attrs['parent_issue_id'].blank?
239 elsif !attrs['parent_issue_id'].blank?
240 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'])
240 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'])
241 end
241 end
242 end
242 end
243
243
244 self.attributes = attrs
244 self.attributes = attrs
245 end
245 end
246
246
247 def done_ratio
247 def done_ratio
248 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio?
248 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio?
249 status.default_done_ratio
249 status.default_done_ratio
250 else
250 else
251 read_attribute(:done_ratio)
251 read_attribute(:done_ratio)
252 end
252 end
253 end
253 end
254
254
255 def self.use_status_for_done_ratio?
255 def self.use_status_for_done_ratio?
256 Setting.issue_done_ratio == 'issue_status'
256 Setting.issue_done_ratio == 'issue_status'
257 end
257 end
258
258
259 def self.use_field_for_done_ratio?
259 def self.use_field_for_done_ratio?
260 Setting.issue_done_ratio == 'issue_field'
260 Setting.issue_done_ratio == 'issue_field'
261 end
261 end
262
262
263 def validate
263 def validate
264 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
264 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
265 errors.add :due_date, :not_a_date
265 errors.add :due_date, :not_a_date
266 end
266 end
267
267
268 if self.due_date and self.start_date and self.due_date < self.start_date
268 if self.due_date and self.start_date and self.due_date < self.start_date
269 errors.add :due_date, :greater_than_start_date
269 errors.add :due_date, :greater_than_start_date
270 end
270 end
271
271
272 if start_date && soonest_start && start_date < soonest_start
272 if start_date && soonest_start && start_date < soonest_start
273 errors.add :start_date, :invalid
273 errors.add :start_date, :invalid
274 end
274 end
275
275
276 if fixed_version
276 if fixed_version
277 if !assignable_versions.include?(fixed_version)
277 if !assignable_versions.include?(fixed_version)
278 errors.add :fixed_version_id, :inclusion
278 errors.add :fixed_version_id, :inclusion
279 elsif reopened? && fixed_version.closed?
279 elsif reopened? && fixed_version.closed?
280 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
280 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
281 end
281 end
282 end
282 end
283
283
284 # Checks that the issue can not be added/moved to a disabled tracker
284 # Checks that the issue can not be added/moved to a disabled tracker
285 if project && (tracker_id_changed? || project_id_changed?)
285 if project && (tracker_id_changed? || project_id_changed?)
286 unless project.trackers.include?(tracker)
286 unless project.trackers.include?(tracker)
287 errors.add :tracker_id, :inclusion
287 errors.add :tracker_id, :inclusion
288 end
288 end
289 end
289 end
290
290
291 # Checks parent issue assignment
291 # Checks parent issue assignment
292 if @parent_issue
292 if @parent_issue
293 if @parent_issue.project_id != project_id
293 if @parent_issue.project_id != project_id
294 errors.add :parent_issue_id, :not_same_project
294 errors.add :parent_issue_id, :not_same_project
295 elsif !new_record?
295 elsif !new_record?
296 # moving an existing issue
296 # moving an existing issue
297 if @parent_issue.root_id != root_id
297 if @parent_issue.root_id != root_id
298 # we can always move to another tree
298 # we can always move to another tree
299 elsif move_possible?(@parent_issue)
299 elsif move_possible?(@parent_issue)
300 # move accepted inside tree
300 # move accepted inside tree
301 else
301 else
302 errors.add :parent_issue_id, :not_a_valid_parent
302 errors.add :parent_issue_id, :not_a_valid_parent
303 end
303 end
304 end
304 end
305 end
305 end
306 end
306 end
307
307
308 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
308 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
309 # even if the user turns off the setting later
309 # even if the user turns off the setting later
310 def update_done_ratio_from_issue_status
310 def update_done_ratio_from_issue_status
311 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio?
311 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio?
312 self.done_ratio = status.default_done_ratio
312 self.done_ratio = status.default_done_ratio
313 end
313 end
314 end
314 end
315
315
316 def init_journal(user, notes = "")
316 def init_journal(user, notes = "")
317 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
317 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
318 @issue_before_change = self.clone
318 @issue_before_change = self.clone
319 @issue_before_change.status = self.status
319 @issue_before_change.status = self.status
320 @custom_values_before_change = {}
320 @custom_values_before_change = {}
321 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
321 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
322 # Make sure updated_on is updated when adding a note.
322 # Make sure updated_on is updated when adding a note.
323 updated_on_will_change!
323 updated_on_will_change!
324 @current_journal
324 @current_journal
325 end
325 end
326
326
327 # Return true if the issue is closed, otherwise false
327 # Return true if the issue is closed, otherwise false
328 def closed?
328 def closed?
329 self.status.is_closed?
329 self.status.is_closed?
330 end
330 end
331
331
332 # Return true if the issue is being reopened
332 # Return true if the issue is being reopened
333 def reopened?
333 def reopened?
334 if !new_record? && status_id_changed?
334 if !new_record? && status_id_changed?
335 status_was = IssueStatus.find_by_id(status_id_was)
335 status_was = IssueStatus.find_by_id(status_id_was)
336 status_new = IssueStatus.find_by_id(status_id)
336 status_new = IssueStatus.find_by_id(status_id)
337 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
337 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
338 return true
338 return true
339 end
339 end
340 end
340 end
341 false
341 false
342 end
342 end
343
343
344 # Return true if the issue is being closed
344 # Return true if the issue is being closed
345 def closing?
345 def closing?
346 if !new_record? && status_id_changed?
346 if !new_record? && status_id_changed?
347 status_was = IssueStatus.find_by_id(status_id_was)
347 status_was = IssueStatus.find_by_id(status_id_was)
348 status_new = IssueStatus.find_by_id(status_id)
348 status_new = IssueStatus.find_by_id(status_id)
349 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
349 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
350 return true
350 return true
351 end
351 end
352 end
352 end
353 false
353 false
354 end
354 end
355
355
356 # Returns true if the issue is overdue
356 # Returns true if the issue is overdue
357 def overdue?
357 def overdue?
358 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
358 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
359 end
359 end
360
360
361 # Users the issue can be assigned to
361 # Users the issue can be assigned to
362 def assignable_users
362 def assignable_users
363 project.assignable_users
363 project.assignable_users
364 end
364 end
365
365
366 # Versions that the issue can be assigned to
366 # Versions that the issue can be assigned to
367 def assignable_versions
367 def assignable_versions
368 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
368 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
369 end
369 end
370
370
371 # Returns true if this issue is blocked by another issue that is still open
371 # Returns true if this issue is blocked by another issue that is still open
372 def blocked?
372 def blocked?
373 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
373 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
374 end
374 end
375
375
376 # Returns an array of status that user is able to apply
376 # Returns an array of status that user is able to apply
377 def new_statuses_allowed_to(user, include_default=false)
377 def new_statuses_allowed_to(user, include_default=false)
378 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
378 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
379 statuses << status unless statuses.empty?
379 statuses << status unless statuses.empty?
380 statuses << IssueStatus.default if include_default
380 statuses << IssueStatus.default if include_default
381 statuses = statuses.uniq.sort
381 statuses = statuses.uniq.sort
382 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
382 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
383 end
383 end
384
384
385 # Returns the mail adresses of users that should be notified
385 # Returns the mail adresses of users that should be notified
386 def recipients
386 def recipients
387 notified = project.notified_users
387 notified = project.notified_users
388 # Author and assignee are always notified unless they have been locked
388 # Author and assignee are always notified unless they have been locked
389 notified << author if author && author.active?
389 notified << author if author && author.active?
390 notified << assigned_to if assigned_to && assigned_to.active?
390 notified << assigned_to if assigned_to && assigned_to.active?
391 notified.uniq!
391 notified.uniq!
392 # Remove users that can not view the issue
392 # Remove users that can not view the issue
393 notified.reject! {|user| !visible?(user)}
393 notified.reject! {|user| !visible?(user)}
394 notified.collect(&:mail)
394 notified.collect(&:mail)
395 end
395 end
396
396
397 # Returns the total number of hours spent on this issue and its descendants
397 # Returns the total number of hours spent on this issue and its descendants
398 #
398 #
399 # Example:
399 # Example:
400 # spent_hours => 0.0
400 # spent_hours => 0.0
401 # spent_hours => 50.2
401 # spent_hours => 50.2
402 def spent_hours
402 def spent_hours
403 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
403 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
404 end
404 end
405
405
406 def relations
406 def relations
407 (relations_from + relations_to).sort
407 (relations_from + relations_to).sort
408 end
408 end
409
409
410 def all_dependent_issues
410 def all_dependent_issues
411 dependencies = []
411 dependencies = []
412 relations_from.each do |relation|
412 relations_from.each do |relation|
413 dependencies << relation.issue_to
413 dependencies << relation.issue_to
414 dependencies += relation.issue_to.all_dependent_issues
414 dependencies += relation.issue_to.all_dependent_issues
415 end
415 end
416 dependencies
416 dependencies
417 end
417 end
418
418
419 # Returns an array of issues that duplicate this one
419 # Returns an array of issues that duplicate this one
420 def duplicates
420 def duplicates
421 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
421 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
422 end
422 end
423
423
424 # Returns the due date or the target due date if any
424 # Returns the due date or the target due date if any
425 # Used on gantt chart
425 # Used on gantt chart
426 def due_before
426 def due_before
427 due_date || (fixed_version ? fixed_version.effective_date : nil)
427 due_date || (fixed_version ? fixed_version.effective_date : nil)
428 end
428 end
429
429
430 # Returns the time scheduled for this issue.
430 # Returns the time scheduled for this issue.
431 #
431 #
432 # Example:
432 # Example:
433 # Start Date: 2/26/09, End Date: 3/04/09
433 # Start Date: 2/26/09, End Date: 3/04/09
434 # duration => 6
434 # duration => 6
435 def duration
435 def duration
436 (start_date && due_date) ? due_date - start_date : 0
436 (start_date && due_date) ? due_date - start_date : 0
437 end
437 end
438
438
439 def soonest_start
439 def soonest_start
440 @soonest_start ||= (
440 @soonest_start ||= (
441 relations_to.collect{|relation| relation.successor_soonest_start} +
441 relations_to.collect{|relation| relation.successor_soonest_start} +
442 ancestors.collect(&:soonest_start)
442 ancestors.collect(&:soonest_start)
443 ).compact.max
443 ).compact.max
444 end
444 end
445
445
446 def reschedule_after(date)
446 def reschedule_after(date)
447 return if date.nil?
447 return if date.nil?
448 if leaf?
448 if leaf?
449 if start_date.nil? || start_date < date
449 if start_date.nil? || start_date < date
450 self.start_date, self.due_date = date, date + duration
450 self.start_date, self.due_date = date, date + duration
451 save
451 save
452 end
452 end
453 else
453 else
454 leaves.each do |leaf|
454 leaves.each do |leaf|
455 leaf.reschedule_after(date)
455 leaf.reschedule_after(date)
456 end
456 end
457 end
457 end
458 end
458 end
459
459
460 def <=>(issue)
460 def <=>(issue)
461 if issue.nil?
461 if issue.nil?
462 -1
462 -1
463 elsif root_id != issue.root_id
463 elsif root_id != issue.root_id
464 (root_id || 0) <=> (issue.root_id || 0)
464 (root_id || 0) <=> (issue.root_id || 0)
465 else
465 else
466 (lft || 0) <=> (issue.lft || 0)
466 (lft || 0) <=> (issue.lft || 0)
467 end
467 end
468 end
468 end
469
469
470 def to_s
470 def to_s
471 "#{tracker} ##{id}: #{subject}"
471 "#{tracker} ##{id}: #{subject}"
472 end
472 end
473
473
474 # Returns a string of css classes that apply to the issue
474 # Returns a string of css classes that apply to the issue
475 def css_classes
475 def css_classes
476 s = "issue status-#{status.position} priority-#{priority.position}"
476 s = "issue status-#{status.position} priority-#{priority.position}"
477 s << ' closed' if closed?
477 s << ' closed' if closed?
478 s << ' overdue' if overdue?
478 s << ' overdue' if overdue?
479 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
479 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
480 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
480 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
481 s
481 s
482 end
482 end
483
483
484 # Saves an issue, time_entry, attachments, and a journal from the parameters
484 # Saves an issue, time_entry, attachments, and a journal from the parameters
485 # Returns false if save fails
485 # Returns false if save fails
486 def save_issue_with_child_records(params, existing_time_entry=nil)
486 def save_issue_with_child_records(params, existing_time_entry=nil)
487 Issue.transaction do
487 Issue.transaction do
488 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
488 if params[:time_entry] && params[:time_entry][:hours].present? && User.current.allowed_to?(:log_time, project)
489 @time_entry = existing_time_entry || TimeEntry.new
489 @time_entry = existing_time_entry || TimeEntry.new
490 @time_entry.project = project
490 @time_entry.project = project
491 @time_entry.issue = self
491 @time_entry.issue = self
492 @time_entry.user = User.current
492 @time_entry.user = User.current
493 @time_entry.spent_on = Date.today
493 @time_entry.spent_on = Date.today
494 @time_entry.attributes = params[:time_entry]
494 @time_entry.attributes = params[:time_entry]
495 self.time_entries << @time_entry
495 self.time_entries << @time_entry
496 end
496 end
497
497
498 if valid?
498 if valid?
499 attachments = Attachment.attach_files(self, params[:attachments])
499 attachments = Attachment.attach_files(self, params[:attachments])
500
500
501 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
501 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
502 # TODO: Rename hook
502 # TODO: Rename hook
503 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
503 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
504 begin
504 begin
505 if save
505 if save
506 # TODO: Rename hook
506 # TODO: Rename hook
507 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
507 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
508 else
508 else
509 raise ActiveRecord::Rollback
509 raise ActiveRecord::Rollback
510 end
510 end
511 rescue ActiveRecord::StaleObjectError
511 rescue ActiveRecord::StaleObjectError
512 attachments[:files].each(&:destroy)
512 attachments[:files].each(&:destroy)
513 errors.add_to_base l(:notice_locking_conflict)
513 errors.add_to_base l(:notice_locking_conflict)
514 raise ActiveRecord::Rollback
514 raise ActiveRecord::Rollback
515 end
515 end
516 end
516 end
517 end
517 end
518 end
518 end
519
519
520 # Unassigns issues from +version+ if it's no longer shared with issue's project
520 # Unassigns issues from +version+ if it's no longer shared with issue's project
521 def self.update_versions_from_sharing_change(version)
521 def self.update_versions_from_sharing_change(version)
522 # Update issues assigned to the version
522 # Update issues assigned to the version
523 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
523 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
524 end
524 end
525
525
526 # Unassigns issues from versions that are no longer shared
526 # Unassigns issues from versions that are no longer shared
527 # after +project+ was moved
527 # after +project+ was moved
528 def self.update_versions_from_hierarchy_change(project)
528 def self.update_versions_from_hierarchy_change(project)
529 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
529 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
530 # Update issues of the moved projects and issues assigned to a version of a moved project
530 # Update issues of the moved projects and issues assigned to a version of a moved project
531 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
531 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
532 end
532 end
533
533
534 def parent_issue_id=(arg)
534 def parent_issue_id=(arg)
535 parent_issue_id = arg.blank? ? nil : arg.to_i
535 parent_issue_id = arg.blank? ? nil : arg.to_i
536 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
536 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
537 @parent_issue.id
537 @parent_issue.id
538 else
538 else
539 @parent_issue = nil
539 @parent_issue = nil
540 nil
540 nil
541 end
541 end
542 end
542 end
543
543
544 def parent_issue_id
544 def parent_issue_id
545 if instance_variable_defined? :@parent_issue
545 if instance_variable_defined? :@parent_issue
546 @parent_issue.nil? ? nil : @parent_issue.id
546 @parent_issue.nil? ? nil : @parent_issue.id
547 else
547 else
548 parent_id
548 parent_id
549 end
549 end
550 end
550 end
551
551
552 # Extracted from the ReportsController.
552 # Extracted from the ReportsController.
553 def self.by_tracker(project)
553 def self.by_tracker(project)
554 count_and_group_by(:project => project,
554 count_and_group_by(:project => project,
555 :field => 'tracker_id',
555 :field => 'tracker_id',
556 :joins => Tracker.table_name)
556 :joins => Tracker.table_name)
557 end
557 end
558
558
559 def self.by_version(project)
559 def self.by_version(project)
560 count_and_group_by(:project => project,
560 count_and_group_by(:project => project,
561 :field => 'fixed_version_id',
561 :field => 'fixed_version_id',
562 :joins => Version.table_name)
562 :joins => Version.table_name)
563 end
563 end
564
564
565 def self.by_priority(project)
565 def self.by_priority(project)
566 count_and_group_by(:project => project,
566 count_and_group_by(:project => project,
567 :field => 'priority_id',
567 :field => 'priority_id',
568 :joins => IssuePriority.table_name)
568 :joins => IssuePriority.table_name)
569 end
569 end
570
570
571 def self.by_category(project)
571 def self.by_category(project)
572 count_and_group_by(:project => project,
572 count_and_group_by(:project => project,
573 :field => 'category_id',
573 :field => 'category_id',
574 :joins => IssueCategory.table_name)
574 :joins => IssueCategory.table_name)
575 end
575 end
576
576
577 def self.by_assigned_to(project)
577 def self.by_assigned_to(project)
578 count_and_group_by(:project => project,
578 count_and_group_by(:project => project,
579 :field => 'assigned_to_id',
579 :field => 'assigned_to_id',
580 :joins => User.table_name)
580 :joins => User.table_name)
581 end
581 end
582
582
583 def self.by_author(project)
583 def self.by_author(project)
584 count_and_group_by(:project => project,
584 count_and_group_by(:project => project,
585 :field => 'author_id',
585 :field => 'author_id',
586 :joins => User.table_name)
586 :joins => User.table_name)
587 end
587 end
588
588
589 def self.by_subproject(project)
589 def self.by_subproject(project)
590 ActiveRecord::Base.connection.select_all("select s.id as status_id,
590 ActiveRecord::Base.connection.select_all("select s.id as status_id,
591 s.is_closed as closed,
591 s.is_closed as closed,
592 i.project_id as project_id,
592 i.project_id as project_id,
593 count(i.id) as total
593 count(i.id) as total
594 from
594 from
595 #{Issue.table_name} i, #{IssueStatus.table_name} s
595 #{Issue.table_name} i, #{IssueStatus.table_name} s
596 where
596 where
597 i.status_id=s.id
597 i.status_id=s.id
598 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
598 and i.project_id IN (#{project.descendants.active.collect{|p| p.id}.join(',')})
599 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
599 group by s.id, s.is_closed, i.project_id") if project.descendants.active.any?
600 end
600 end
601 # End ReportsController extraction
601 # End ReportsController extraction
602
602
603 # Returns an array of projects that current user can move issues to
604 def self.allowed_target_projects_on_move
605 projects = []
606 if User.current.admin?
607 # admin is allowed to move issues to any active (visible) project
608 projects = Project.visible.all
609 elsif User.current.logged?
610 if Role.non_member.allowed_to?(:move_issues)
611 projects = Project.visible.all
612 else
613 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
614 end
615 end
616 projects
617 end
618
603 private
619 private
604
620
605 def update_nested_set_attributes
621 def update_nested_set_attributes
606 if root_id.nil?
622 if root_id.nil?
607 # issue was just created
623 # issue was just created
608 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
624 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
609 set_default_left_and_right
625 set_default_left_and_right
610 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
626 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
611 if @parent_issue
627 if @parent_issue
612 move_to_child_of(@parent_issue)
628 move_to_child_of(@parent_issue)
613 end
629 end
614 reload
630 reload
615 elsif parent_issue_id != parent_id
631 elsif parent_issue_id != parent_id
616 # moving an existing issue
632 # moving an existing issue
617 if @parent_issue && @parent_issue.root_id == root_id
633 if @parent_issue && @parent_issue.root_id == root_id
618 # inside the same tree
634 # inside the same tree
619 move_to_child_of(@parent_issue)
635 move_to_child_of(@parent_issue)
620 else
636 else
621 # to another tree
637 # to another tree
622 unless root?
638 unless root?
623 move_to_right_of(root)
639 move_to_right_of(root)
624 reload
640 reload
625 end
641 end
626 old_root_id = root_id
642 old_root_id = root_id
627 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
643 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
628 target_maxright = nested_set_scope.maximum(right_column_name) || 0
644 target_maxright = nested_set_scope.maximum(right_column_name) || 0
629 offset = target_maxright + 1 - lft
645 offset = target_maxright + 1 - lft
630 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
646 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
631 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
647 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
632 self[left_column_name] = lft + offset
648 self[left_column_name] = lft + offset
633 self[right_column_name] = rgt + offset
649 self[right_column_name] = rgt + offset
634 if @parent_issue
650 if @parent_issue
635 move_to_child_of(@parent_issue)
651 move_to_child_of(@parent_issue)
636 end
652 end
637 end
653 end
638 reload
654 reload
639 # delete invalid relations of all descendants
655 # delete invalid relations of all descendants
640 self_and_descendants.each do |issue|
656 self_and_descendants.each do |issue|
641 issue.relations.each do |relation|
657 issue.relations.each do |relation|
642 relation.destroy unless relation.valid?
658 relation.destroy unless relation.valid?
643 end
659 end
644 end
660 end
645 end
661 end
646 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
662 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
647 end
663 end
648
664
649 def update_parent_attributes
665 def update_parent_attributes
650 if parent_id && p = Issue.find_by_id(parent_id)
666 if parent_id && p = Issue.find_by_id(parent_id)
651 # priority = highest priority of children
667 # priority = highest priority of children
652 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
668 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
653 p.priority = IssuePriority.find_by_position(priority_position)
669 p.priority = IssuePriority.find_by_position(priority_position)
654 end
670 end
655
671
656 # start/due dates = lowest/highest dates of children
672 # start/due dates = lowest/highest dates of children
657 p.start_date = p.children.minimum(:start_date)
673 p.start_date = p.children.minimum(:start_date)
658 p.due_date = p.children.maximum(:due_date)
674 p.due_date = p.children.maximum(:due_date)
659 if p.start_date && p.due_date && p.due_date < p.start_date
675 if p.start_date && p.due_date && p.due_date < p.start_date
660 p.start_date, p.due_date = p.due_date, p.start_date
676 p.start_date, p.due_date = p.due_date, p.start_date
661 end
677 end
662
678
663 # done ratio = weighted average ratio of leaves
679 # done ratio = weighted average ratio of leaves
664 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio?
680 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio?
665 leaves_count = p.leaves.count
681 leaves_count = p.leaves.count
666 if leaves_count > 0
682 if leaves_count > 0
667 average = p.leaves.average(:estimated_hours).to_f
683 average = p.leaves.average(:estimated_hours).to_f
668 if average == 0
684 if average == 0
669 average = 1
685 average = 1
670 end
686 end
671 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
687 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
672 progress = done / (average * leaves_count)
688 progress = done / (average * leaves_count)
673 p.done_ratio = progress.round
689 p.done_ratio = progress.round
674 end
690 end
675 end
691 end
676
692
677 # estimate = sum of leaves estimates
693 # estimate = sum of leaves estimates
678 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
694 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
679 p.estimated_hours = nil if p.estimated_hours == 0.0
695 p.estimated_hours = nil if p.estimated_hours == 0.0
680
696
681 # ancestors will be recursively updated
697 # ancestors will be recursively updated
682 p.save(false)
698 p.save(false)
683 end
699 end
684 end
700 end
685
701
686 def destroy_children
702 def destroy_children
687 unless leaf?
703 unless leaf?
688 children.each do |child|
704 children.each do |child|
689 child.destroy
705 child.destroy
690 end
706 end
691 end
707 end
692 end
708 end
693
709
694 # Update issues so their versions are not pointing to a
710 # Update issues so their versions are not pointing to a
695 # fixed_version that is not shared with the issue's project
711 # fixed_version that is not shared with the issue's project
696 def self.update_versions(conditions=nil)
712 def self.update_versions(conditions=nil)
697 # Only need to update issues with a fixed_version from
713 # Only need to update issues with a fixed_version from
698 # a different project and that is not systemwide shared
714 # a different project and that is not systemwide shared
699 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
715 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
700 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
716 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
701 " AND #{Version.table_name}.sharing <> 'system'",
717 " AND #{Version.table_name}.sharing <> 'system'",
702 conditions),
718 conditions),
703 :include => [:project, :fixed_version]
719 :include => [:project, :fixed_version]
704 ).each do |issue|
720 ).each do |issue|
705 next if issue.project.nil? || issue.fixed_version.nil?
721 next if issue.project.nil? || issue.fixed_version.nil?
706 unless issue.project.shared_versions.include?(issue.fixed_version)
722 unless issue.project.shared_versions.include?(issue.fixed_version)
707 issue.init_journal(User.current)
723 issue.init_journal(User.current)
708 issue.fixed_version = nil
724 issue.fixed_version = nil
709 issue.save
725 issue.save
710 end
726 end
711 end
727 end
712 end
728 end
713
729
714 # Callback on attachment deletion
730 # Callback on attachment deletion
715 def attachment_removed(obj)
731 def attachment_removed(obj)
716 journal = init_journal(User.current)
732 journal = init_journal(User.current)
717 journal.details << JournalDetail.new(:property => 'attachment',
733 journal.details << JournalDetail.new(:property => 'attachment',
718 :prop_key => obj.id,
734 :prop_key => obj.id,
719 :old_value => obj.filename)
735 :old_value => obj.filename)
720 journal.save
736 journal.save
721 end
737 end
722
738
723 # Default assignment based on category
739 # Default assignment based on category
724 def default_assign
740 def default_assign
725 if assigned_to.nil? && category && category.assigned_to
741 if assigned_to.nil? && category && category.assigned_to
726 self.assigned_to = category.assigned_to
742 self.assigned_to = category.assigned_to
727 end
743 end
728 end
744 end
729
745
730 # Updates start/due dates of following issues
746 # Updates start/due dates of following issues
731 def reschedule_following_issues
747 def reschedule_following_issues
732 if start_date_changed? || due_date_changed?
748 if start_date_changed? || due_date_changed?
733 relations_from.each do |relation|
749 relations_from.each do |relation|
734 relation.set_issue_to_dates
750 relation.set_issue_to_dates
735 end
751 end
736 end
752 end
737 end
753 end
738
754
739 # Closes duplicates if the issue is being closed
755 # Closes duplicates if the issue is being closed
740 def close_duplicates
756 def close_duplicates
741 if closing?
757 if closing?
742 duplicates.each do |duplicate|
758 duplicates.each do |duplicate|
743 # Reload is need in case the duplicate was updated by a previous duplicate
759 # Reload is need in case the duplicate was updated by a previous duplicate
744 duplicate.reload
760 duplicate.reload
745 # Don't re-close it if it's already closed
761 # Don't re-close it if it's already closed
746 next if duplicate.closed?
762 next if duplicate.closed?
747 # Same user and notes
763 # Same user and notes
748 if @current_journal
764 if @current_journal
749 duplicate.init_journal(@current_journal.user, @current_journal.notes)
765 duplicate.init_journal(@current_journal.user, @current_journal.notes)
750 end
766 end
751 duplicate.update_attribute :status, self.status
767 duplicate.update_attribute :status, self.status
752 end
768 end
753 end
769 end
754 end
770 end
755
771
756 # Saves the changes in a Journal
772 # Saves the changes in a Journal
757 # Called after_save
773 # Called after_save
758 def create_journal
774 def create_journal
759 if @current_journal
775 if @current_journal
760 # attributes changes
776 # attributes changes
761 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
777 (Issue.column_names - %w(id description root_id lft rgt lock_version created_on updated_on)).each {|c|
762 @current_journal.details << JournalDetail.new(:property => 'attr',
778 @current_journal.details << JournalDetail.new(:property => 'attr',
763 :prop_key => c,
779 :prop_key => c,
764 :old_value => @issue_before_change.send(c),
780 :old_value => @issue_before_change.send(c),
765 :value => send(c)) unless send(c)==@issue_before_change.send(c)
781 :value => send(c)) unless send(c)==@issue_before_change.send(c)
766 }
782 }
767 # custom fields changes
783 # custom fields changes
768 custom_values.each {|c|
784 custom_values.each {|c|
769 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
785 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
770 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
786 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
771 @current_journal.details << JournalDetail.new(:property => 'cf',
787 @current_journal.details << JournalDetail.new(:property => 'cf',
772 :prop_key => c.custom_field_id,
788 :prop_key => c.custom_field_id,
773 :old_value => @custom_values_before_change[c.custom_field_id],
789 :old_value => @custom_values_before_change[c.custom_field_id],
774 :value => c.value)
790 :value => c.value)
775 }
791 }
776 @current_journal.save
792 @current_journal.save
777 # reset current journal
793 # reset current journal
778 init_journal @current_journal.user, @current_journal.notes
794 init_journal @current_journal.user, @current_journal.notes
779 end
795 end
780 end
796 end
781
797
782 # Query generator for selecting groups of issue counts for a project
798 # Query generator for selecting groups of issue counts for a project
783 # based on specific criteria
799 # based on specific criteria
784 #
800 #
785 # Options
801 # Options
786 # * project - Project to search in.
802 # * project - Project to search in.
787 # * field - String. Issue field to key off of in the grouping.
803 # * field - String. Issue field to key off of in the grouping.
788 # * joins - String. The table name to join against.
804 # * joins - String. The table name to join against.
789 def self.count_and_group_by(options)
805 def self.count_and_group_by(options)
790 project = options.delete(:project)
806 project = options.delete(:project)
791 select_field = options.delete(:field)
807 select_field = options.delete(:field)
792 joins = options.delete(:joins)
808 joins = options.delete(:joins)
793
809
794 where = "i.#{select_field}=j.id"
810 where = "i.#{select_field}=j.id"
795
811
796 ActiveRecord::Base.connection.select_all("select s.id as status_id,
812 ActiveRecord::Base.connection.select_all("select s.id as status_id,
797 s.is_closed as closed,
813 s.is_closed as closed,
798 j.id as #{select_field},
814 j.id as #{select_field},
799 count(i.id) as total
815 count(i.id) as total
800 from
816 from
801 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} as j
817 #{Issue.table_name} i, #{IssueStatus.table_name} s, #{joins} as j
802 where
818 where
803 i.status_id=s.id
819 i.status_id=s.id
804 and #{where}
820 and #{where}
805 and i.project_id=#{project.id}
821 and i.project_id=#{project.id}
806 group by s.id, s.is_closed, j.id")
822 group by s.id, s.is_closed, j.id")
807 end
823 end
808
824
809
825
810 end
826 end
@@ -1,690 +1,707
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.dirname(__FILE__) + '/../test_helper'
18 require File.dirname(__FILE__) + '/../test_helper'
19
19
20 class IssueTest < ActiveSupport::TestCase
20 class IssueTest < ActiveSupport::TestCase
21 fixtures :projects, :users, :members, :member_roles, :roles,
21 fixtures :projects, :users, :members, :member_roles, :roles,
22 :trackers, :projects_trackers,
22 :trackers, :projects_trackers,
23 :enabled_modules,
23 :enabled_modules,
24 :versions,
24 :versions,
25 :issue_statuses, :issue_categories, :issue_relations, :workflows,
25 :issue_statuses, :issue_categories, :issue_relations, :workflows,
26 :enumerations,
26 :enumerations,
27 :issues,
27 :issues,
28 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
28 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
29 :time_entries
29 :time_entries
30
30
31 def test_create
31 def test_create
32 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :description => 'IssueTest#test_create', :estimated_hours => '1:30')
32 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :description => 'IssueTest#test_create', :estimated_hours => '1:30')
33 assert issue.save
33 assert issue.save
34 issue.reload
34 issue.reload
35 assert_equal 1.5, issue.estimated_hours
35 assert_equal 1.5, issue.estimated_hours
36 end
36 end
37
37
38 def test_create_minimal
38 def test_create_minimal
39 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create')
39 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create')
40 assert issue.save
40 assert issue.save
41 assert issue.description.nil?
41 assert issue.description.nil?
42 end
42 end
43
43
44 def test_create_with_required_custom_field
44 def test_create_with_required_custom_field
45 field = IssueCustomField.find_by_name('Database')
45 field = IssueCustomField.find_by_name('Database')
46 field.update_attribute(:is_required, true)
46 field.update_attribute(:is_required, true)
47
47
48 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'test_create', :description => 'IssueTest#test_create_with_required_custom_field')
48 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'test_create', :description => 'IssueTest#test_create_with_required_custom_field')
49 assert issue.available_custom_fields.include?(field)
49 assert issue.available_custom_fields.include?(field)
50 # No value for the custom field
50 # No value for the custom field
51 assert !issue.save
51 assert !issue.save
52 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
52 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
53 # Blank value
53 # Blank value
54 issue.custom_field_values = { field.id => '' }
54 issue.custom_field_values = { field.id => '' }
55 assert !issue.save
55 assert !issue.save
56 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
56 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
57 # Invalid value
57 # Invalid value
58 issue.custom_field_values = { field.id => 'SQLServer' }
58 issue.custom_field_values = { field.id => 'SQLServer' }
59 assert !issue.save
59 assert !issue.save
60 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
60 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
61 # Valid value
61 # Valid value
62 issue.custom_field_values = { field.id => 'PostgreSQL' }
62 issue.custom_field_values = { field.id => 'PostgreSQL' }
63 assert issue.save
63 assert issue.save
64 issue.reload
64 issue.reload
65 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
65 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
66 end
66 end
67
67
68 def test_visible_scope_for_anonymous
68 def test_visible_scope_for_anonymous
69 # Anonymous user should see issues of public projects only
69 # Anonymous user should see issues of public projects only
70 issues = Issue.visible(User.anonymous).all
70 issues = Issue.visible(User.anonymous).all
71 assert issues.any?
71 assert issues.any?
72 assert_nil issues.detect {|issue| !issue.project.is_public?}
72 assert_nil issues.detect {|issue| !issue.project.is_public?}
73 # Anonymous user should not see issues without permission
73 # Anonymous user should not see issues without permission
74 Role.anonymous.remove_permission!(:view_issues)
74 Role.anonymous.remove_permission!(:view_issues)
75 issues = Issue.visible(User.anonymous).all
75 issues = Issue.visible(User.anonymous).all
76 assert issues.empty?
76 assert issues.empty?
77 end
77 end
78
78
79 def test_visible_scope_for_user
79 def test_visible_scope_for_user
80 user = User.find(9)
80 user = User.find(9)
81 assert user.projects.empty?
81 assert user.projects.empty?
82 # Non member user should see issues of public projects only
82 # Non member user should see issues of public projects only
83 issues = Issue.visible(user).all
83 issues = Issue.visible(user).all
84 assert issues.any?
84 assert issues.any?
85 assert_nil issues.detect {|issue| !issue.project.is_public?}
85 assert_nil issues.detect {|issue| !issue.project.is_public?}
86 # Non member user should not see issues without permission
86 # Non member user should not see issues without permission
87 Role.non_member.remove_permission!(:view_issues)
87 Role.non_member.remove_permission!(:view_issues)
88 user.reload
88 user.reload
89 issues = Issue.visible(user).all
89 issues = Issue.visible(user).all
90 assert issues.empty?
90 assert issues.empty?
91 # User should see issues of projects for which he has view_issues permissions only
91 # User should see issues of projects for which he has view_issues permissions only
92 Member.create!(:principal => user, :project_id => 2, :role_ids => [1])
92 Member.create!(:principal => user, :project_id => 2, :role_ids => [1])
93 user.reload
93 user.reload
94 issues = Issue.visible(user).all
94 issues = Issue.visible(user).all
95 assert issues.any?
95 assert issues.any?
96 assert_nil issues.detect {|issue| issue.project_id != 2}
96 assert_nil issues.detect {|issue| issue.project_id != 2}
97 end
97 end
98
98
99 def test_visible_scope_for_admin
99 def test_visible_scope_for_admin
100 user = User.find(1)
100 user = User.find(1)
101 user.members.each(&:destroy)
101 user.members.each(&:destroy)
102 assert user.projects.empty?
102 assert user.projects.empty?
103 issues = Issue.visible(user).all
103 issues = Issue.visible(user).all
104 assert issues.any?
104 assert issues.any?
105 # Admin should see issues on private projects that he does not belong to
105 # Admin should see issues on private projects that he does not belong to
106 assert issues.detect {|issue| !issue.project.is_public?}
106 assert issues.detect {|issue| !issue.project.is_public?}
107 end
107 end
108
108
109 def test_errors_full_messages_should_include_custom_fields_errors
109 def test_errors_full_messages_should_include_custom_fields_errors
110 field = IssueCustomField.find_by_name('Database')
110 field = IssueCustomField.find_by_name('Database')
111
111
112 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'test_create', :description => 'IssueTest#test_create_with_required_custom_field')
112 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'test_create', :description => 'IssueTest#test_create_with_required_custom_field')
113 assert issue.available_custom_fields.include?(field)
113 assert issue.available_custom_fields.include?(field)
114 # Invalid value
114 # Invalid value
115 issue.custom_field_values = { field.id => 'SQLServer' }
115 issue.custom_field_values = { field.id => 'SQLServer' }
116
116
117 assert !issue.valid?
117 assert !issue.valid?
118 assert_equal 1, issue.errors.full_messages.size
118 assert_equal 1, issue.errors.full_messages.size
119 assert_equal "Database #{I18n.translate('activerecord.errors.messages.inclusion')}", issue.errors.full_messages.first
119 assert_equal "Database #{I18n.translate('activerecord.errors.messages.inclusion')}", issue.errors.full_messages.first
120 end
120 end
121
121
122 def test_update_issue_with_required_custom_field
122 def test_update_issue_with_required_custom_field
123 field = IssueCustomField.find_by_name('Database')
123 field = IssueCustomField.find_by_name('Database')
124 field.update_attribute(:is_required, true)
124 field.update_attribute(:is_required, true)
125
125
126 issue = Issue.find(1)
126 issue = Issue.find(1)
127 assert_nil issue.custom_value_for(field)
127 assert_nil issue.custom_value_for(field)
128 assert issue.available_custom_fields.include?(field)
128 assert issue.available_custom_fields.include?(field)
129 # No change to custom values, issue can be saved
129 # No change to custom values, issue can be saved
130 assert issue.save
130 assert issue.save
131 # Blank value
131 # Blank value
132 issue.custom_field_values = { field.id => '' }
132 issue.custom_field_values = { field.id => '' }
133 assert !issue.save
133 assert !issue.save
134 # Valid value
134 # Valid value
135 issue.custom_field_values = { field.id => 'PostgreSQL' }
135 issue.custom_field_values = { field.id => 'PostgreSQL' }
136 assert issue.save
136 assert issue.save
137 issue.reload
137 issue.reload
138 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
138 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
139 end
139 end
140
140
141 def test_should_not_update_attributes_if_custom_fields_validation_fails
141 def test_should_not_update_attributes_if_custom_fields_validation_fails
142 issue = Issue.find(1)
142 issue = Issue.find(1)
143 field = IssueCustomField.find_by_name('Database')
143 field = IssueCustomField.find_by_name('Database')
144 assert issue.available_custom_fields.include?(field)
144 assert issue.available_custom_fields.include?(field)
145
145
146 issue.custom_field_values = { field.id => 'Invalid' }
146 issue.custom_field_values = { field.id => 'Invalid' }
147 issue.subject = 'Should be not be saved'
147 issue.subject = 'Should be not be saved'
148 assert !issue.save
148 assert !issue.save
149
149
150 issue.reload
150 issue.reload
151 assert_equal "Can't print recipes", issue.subject
151 assert_equal "Can't print recipes", issue.subject
152 end
152 end
153
153
154 def test_should_not_recreate_custom_values_objects_on_update
154 def test_should_not_recreate_custom_values_objects_on_update
155 field = IssueCustomField.find_by_name('Database')
155 field = IssueCustomField.find_by_name('Database')
156
156
157 issue = Issue.find(1)
157 issue = Issue.find(1)
158 issue.custom_field_values = { field.id => 'PostgreSQL' }
158 issue.custom_field_values = { field.id => 'PostgreSQL' }
159 assert issue.save
159 assert issue.save
160 custom_value = issue.custom_value_for(field)
160 custom_value = issue.custom_value_for(field)
161 issue.reload
161 issue.reload
162 issue.custom_field_values = { field.id => 'MySQL' }
162 issue.custom_field_values = { field.id => 'MySQL' }
163 assert issue.save
163 assert issue.save
164 issue.reload
164 issue.reload
165 assert_equal custom_value.id, issue.custom_value_for(field).id
165 assert_equal custom_value.id, issue.custom_value_for(field).id
166 end
166 end
167
167
168 def test_assigning_tracker_id_should_reload_custom_fields_values
168 def test_assigning_tracker_id_should_reload_custom_fields_values
169 issue = Issue.new(:project => Project.find(1))
169 issue = Issue.new(:project => Project.find(1))
170 assert issue.custom_field_values.empty?
170 assert issue.custom_field_values.empty?
171 issue.tracker_id = 1
171 issue.tracker_id = 1
172 assert issue.custom_field_values.any?
172 assert issue.custom_field_values.any?
173 end
173 end
174
174
175 def test_assigning_attributes_should_assign_tracker_id_first
175 def test_assigning_attributes_should_assign_tracker_id_first
176 attributes = ActiveSupport::OrderedHash.new
176 attributes = ActiveSupport::OrderedHash.new
177 attributes['custom_field_values'] = { '1' => 'MySQL' }
177 attributes['custom_field_values'] = { '1' => 'MySQL' }
178 attributes['tracker_id'] = '1'
178 attributes['tracker_id'] = '1'
179 issue = Issue.new(:project => Project.find(1))
179 issue = Issue.new(:project => Project.find(1))
180 issue.attributes = attributes
180 issue.attributes = attributes
181 assert_not_nil issue.custom_value_for(1)
181 assert_not_nil issue.custom_value_for(1)
182 assert_equal 'MySQL', issue.custom_value_for(1).value
182 assert_equal 'MySQL', issue.custom_value_for(1).value
183 end
183 end
184
184
185 def test_should_update_issue_with_disabled_tracker
185 def test_should_update_issue_with_disabled_tracker
186 p = Project.find(1)
186 p = Project.find(1)
187 issue = Issue.find(1)
187 issue = Issue.find(1)
188
188
189 p.trackers.delete(issue.tracker)
189 p.trackers.delete(issue.tracker)
190 assert !p.trackers.include?(issue.tracker)
190 assert !p.trackers.include?(issue.tracker)
191
191
192 issue.reload
192 issue.reload
193 issue.subject = 'New subject'
193 issue.subject = 'New subject'
194 assert issue.save
194 assert issue.save
195 end
195 end
196
196
197 def test_should_not_set_a_disabled_tracker
197 def test_should_not_set_a_disabled_tracker
198 p = Project.find(1)
198 p = Project.find(1)
199 p.trackers.delete(Tracker.find(2))
199 p.trackers.delete(Tracker.find(2))
200
200
201 issue = Issue.find(1)
201 issue = Issue.find(1)
202 issue.tracker_id = 2
202 issue.tracker_id = 2
203 issue.subject = 'New subject'
203 issue.subject = 'New subject'
204 assert !issue.save
204 assert !issue.save
205 assert_not_nil issue.errors.on(:tracker_id)
205 assert_not_nil issue.errors.on(:tracker_id)
206 end
206 end
207
207
208 def test_category_based_assignment
208 def test_category_based_assignment
209 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Assignment test', :description => 'Assignment test', :category_id => 1)
209 issue = Issue.create(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Assignment test', :description => 'Assignment test', :category_id => 1)
210 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
210 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
211 end
211 end
212
212
213 def test_copy
213 def test_copy
214 issue = Issue.new.copy_from(1)
214 issue = Issue.new.copy_from(1)
215 assert issue.save
215 assert issue.save
216 issue.reload
216 issue.reload
217 orig = Issue.find(1)
217 orig = Issue.find(1)
218 assert_equal orig.subject, issue.subject
218 assert_equal orig.subject, issue.subject
219 assert_equal orig.tracker, issue.tracker
219 assert_equal orig.tracker, issue.tracker
220 assert_equal "125", issue.custom_value_for(2).value
220 assert_equal "125", issue.custom_value_for(2).value
221 end
221 end
222
222
223 def test_copy_should_copy_status
223 def test_copy_should_copy_status
224 orig = Issue.find(8)
224 orig = Issue.find(8)
225 assert orig.status != IssueStatus.default
225 assert orig.status != IssueStatus.default
226
226
227 issue = Issue.new.copy_from(orig)
227 issue = Issue.new.copy_from(orig)
228 assert issue.save
228 assert issue.save
229 issue.reload
229 issue.reload
230 assert_equal orig.status, issue.status
230 assert_equal orig.status, issue.status
231 end
231 end
232
232
233 def test_should_close_duplicates
233 def test_should_close_duplicates
234 # Create 3 issues
234 # Create 3 issues
235 issue1 = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Duplicates test', :description => 'Duplicates test')
235 issue1 = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Duplicates test', :description => 'Duplicates test')
236 assert issue1.save
236 assert issue1.save
237 issue2 = issue1.clone
237 issue2 = issue1.clone
238 assert issue2.save
238 assert issue2.save
239 issue3 = issue1.clone
239 issue3 = issue1.clone
240 assert issue3.save
240 assert issue3.save
241
241
242 # 2 is a dupe of 1
242 # 2 is a dupe of 1
243 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
243 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
244 # And 3 is a dupe of 2
244 # And 3 is a dupe of 2
245 IssueRelation.create(:issue_from => issue3, :issue_to => issue2, :relation_type => IssueRelation::TYPE_DUPLICATES)
245 IssueRelation.create(:issue_from => issue3, :issue_to => issue2, :relation_type => IssueRelation::TYPE_DUPLICATES)
246 # And 3 is a dupe of 1 (circular duplicates)
246 # And 3 is a dupe of 1 (circular duplicates)
247 IssueRelation.create(:issue_from => issue3, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
247 IssueRelation.create(:issue_from => issue3, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
248
248
249 assert issue1.reload.duplicates.include?(issue2)
249 assert issue1.reload.duplicates.include?(issue2)
250
250
251 # Closing issue 1
251 # Closing issue 1
252 issue1.init_journal(User.find(:first), "Closing issue1")
252 issue1.init_journal(User.find(:first), "Closing issue1")
253 issue1.status = IssueStatus.find :first, :conditions => {:is_closed => true}
253 issue1.status = IssueStatus.find :first, :conditions => {:is_closed => true}
254 assert issue1.save
254 assert issue1.save
255 # 2 and 3 should be also closed
255 # 2 and 3 should be also closed
256 assert issue2.reload.closed?
256 assert issue2.reload.closed?
257 assert issue3.reload.closed?
257 assert issue3.reload.closed?
258 end
258 end
259
259
260 def test_should_not_close_duplicated_issue
260 def test_should_not_close_duplicated_issue
261 # Create 3 issues
261 # Create 3 issues
262 issue1 = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Duplicates test', :description => 'Duplicates test')
262 issue1 = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'Duplicates test', :description => 'Duplicates test')
263 assert issue1.save
263 assert issue1.save
264 issue2 = issue1.clone
264 issue2 = issue1.clone
265 assert issue2.save
265 assert issue2.save
266
266
267 # 2 is a dupe of 1
267 # 2 is a dupe of 1
268 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
268 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
269 # 2 is a dup of 1 but 1 is not a duplicate of 2
269 # 2 is a dup of 1 but 1 is not a duplicate of 2
270 assert !issue2.reload.duplicates.include?(issue1)
270 assert !issue2.reload.duplicates.include?(issue1)
271
271
272 # Closing issue 2
272 # Closing issue 2
273 issue2.init_journal(User.find(:first), "Closing issue2")
273 issue2.init_journal(User.find(:first), "Closing issue2")
274 issue2.status = IssueStatus.find :first, :conditions => {:is_closed => true}
274 issue2.status = IssueStatus.find :first, :conditions => {:is_closed => true}
275 assert issue2.save
275 assert issue2.save
276 # 1 should not be also closed
276 # 1 should not be also closed
277 assert !issue1.reload.closed?
277 assert !issue1.reload.closed?
278 end
278 end
279
279
280 def test_assignable_versions
280 def test_assignable_versions
281 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
281 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
282 assert_equal ['open'], issue.assignable_versions.collect(&:status).uniq
282 assert_equal ['open'], issue.assignable_versions.collect(&:status).uniq
283 end
283 end
284
284
285 def test_should_not_be_able_to_assign_a_new_issue_to_a_closed_version
285 def test_should_not_be_able_to_assign_a_new_issue_to_a_closed_version
286 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
286 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
287 assert !issue.save
287 assert !issue.save
288 assert_not_nil issue.errors.on(:fixed_version_id)
288 assert_not_nil issue.errors.on(:fixed_version_id)
289 end
289 end
290
290
291 def test_should_not_be_able_to_assign_a_new_issue_to_a_locked_version
291 def test_should_not_be_able_to_assign_a_new_issue_to_a_locked_version
292 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 2, :subject => 'New issue')
292 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 2, :subject => 'New issue')
293 assert !issue.save
293 assert !issue.save
294 assert_not_nil issue.errors.on(:fixed_version_id)
294 assert_not_nil issue.errors.on(:fixed_version_id)
295 end
295 end
296
296
297 def test_should_be_able_to_assign_a_new_issue_to_an_open_version
297 def test_should_be_able_to_assign_a_new_issue_to_an_open_version
298 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 3, :subject => 'New issue')
298 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 3, :subject => 'New issue')
299 assert issue.save
299 assert issue.save
300 end
300 end
301
301
302 def test_should_be_able_to_update_an_issue_assigned_to_a_closed_version
302 def test_should_be_able_to_update_an_issue_assigned_to_a_closed_version
303 issue = Issue.find(11)
303 issue = Issue.find(11)
304 assert_equal 'closed', issue.fixed_version.status
304 assert_equal 'closed', issue.fixed_version.status
305 issue.subject = 'Subject changed'
305 issue.subject = 'Subject changed'
306 assert issue.save
306 assert issue.save
307 end
307 end
308
308
309 def test_should_not_be_able_to_reopen_an_issue_assigned_to_a_closed_version
309 def test_should_not_be_able_to_reopen_an_issue_assigned_to_a_closed_version
310 issue = Issue.find(11)
310 issue = Issue.find(11)
311 issue.status_id = 1
311 issue.status_id = 1
312 assert !issue.save
312 assert !issue.save
313 assert_not_nil issue.errors.on_base
313 assert_not_nil issue.errors.on_base
314 end
314 end
315
315
316 def test_should_be_able_to_reopen_and_reassign_an_issue_assigned_to_a_closed_version
316 def test_should_be_able_to_reopen_and_reassign_an_issue_assigned_to_a_closed_version
317 issue = Issue.find(11)
317 issue = Issue.find(11)
318 issue.status_id = 1
318 issue.status_id = 1
319 issue.fixed_version_id = 3
319 issue.fixed_version_id = 3
320 assert issue.save
320 assert issue.save
321 end
321 end
322
322
323 def test_should_be_able_to_reopen_an_issue_assigned_to_a_locked_version
323 def test_should_be_able_to_reopen_an_issue_assigned_to_a_locked_version
324 issue = Issue.find(12)
324 issue = Issue.find(12)
325 assert_equal 'locked', issue.fixed_version.status
325 assert_equal 'locked', issue.fixed_version.status
326 issue.status_id = 1
326 issue.status_id = 1
327 assert issue.save
327 assert issue.save
328 end
328 end
329
329
330 def test_move_to_another_project_with_same_category
330 def test_move_to_another_project_with_same_category
331 issue = Issue.find(1)
331 issue = Issue.find(1)
332 assert issue.move_to_project(Project.find(2))
332 assert issue.move_to_project(Project.find(2))
333 issue.reload
333 issue.reload
334 assert_equal 2, issue.project_id
334 assert_equal 2, issue.project_id
335 # Category changes
335 # Category changes
336 assert_equal 4, issue.category_id
336 assert_equal 4, issue.category_id
337 # Make sure time entries were move to the target project
337 # Make sure time entries were move to the target project
338 assert_equal 2, issue.time_entries.first.project_id
338 assert_equal 2, issue.time_entries.first.project_id
339 end
339 end
340
340
341 def test_move_to_another_project_without_same_category
341 def test_move_to_another_project_without_same_category
342 issue = Issue.find(2)
342 issue = Issue.find(2)
343 assert issue.move_to_project(Project.find(2))
343 assert issue.move_to_project(Project.find(2))
344 issue.reload
344 issue.reload
345 assert_equal 2, issue.project_id
345 assert_equal 2, issue.project_id
346 # Category cleared
346 # Category cleared
347 assert_nil issue.category_id
347 assert_nil issue.category_id
348 end
348 end
349
349
350 def test_move_to_another_project_should_clear_fixed_version_when_not_shared
350 def test_move_to_another_project_should_clear_fixed_version_when_not_shared
351 issue = Issue.find(1)
351 issue = Issue.find(1)
352 issue.update_attribute(:fixed_version_id, 1)
352 issue.update_attribute(:fixed_version_id, 1)
353 assert issue.move_to_project(Project.find(2))
353 assert issue.move_to_project(Project.find(2))
354 issue.reload
354 issue.reload
355 assert_equal 2, issue.project_id
355 assert_equal 2, issue.project_id
356 # Cleared fixed_version
356 # Cleared fixed_version
357 assert_equal nil, issue.fixed_version
357 assert_equal nil, issue.fixed_version
358 end
358 end
359
359
360 def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
360 def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
361 issue = Issue.find(1)
361 issue = Issue.find(1)
362 issue.update_attribute(:fixed_version_id, 4)
362 issue.update_attribute(:fixed_version_id, 4)
363 assert issue.move_to_project(Project.find(5))
363 assert issue.move_to_project(Project.find(5))
364 issue.reload
364 issue.reload
365 assert_equal 5, issue.project_id
365 assert_equal 5, issue.project_id
366 # Keep fixed_version
366 # Keep fixed_version
367 assert_equal 4, issue.fixed_version_id
367 assert_equal 4, issue.fixed_version_id
368 end
368 end
369
369
370 def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
370 def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
371 issue = Issue.find(1)
371 issue = Issue.find(1)
372 issue.update_attribute(:fixed_version_id, 1)
372 issue.update_attribute(:fixed_version_id, 1)
373 assert issue.move_to_project(Project.find(5))
373 assert issue.move_to_project(Project.find(5))
374 issue.reload
374 issue.reload
375 assert_equal 5, issue.project_id
375 assert_equal 5, issue.project_id
376 # Cleared fixed_version
376 # Cleared fixed_version
377 assert_equal nil, issue.fixed_version
377 assert_equal nil, issue.fixed_version
378 end
378 end
379
379
380 def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
380 def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
381 issue = Issue.find(1)
381 issue = Issue.find(1)
382 issue.update_attribute(:fixed_version_id, 7)
382 issue.update_attribute(:fixed_version_id, 7)
383 assert issue.move_to_project(Project.find(2))
383 assert issue.move_to_project(Project.find(2))
384 issue.reload
384 issue.reload
385 assert_equal 2, issue.project_id
385 assert_equal 2, issue.project_id
386 # Keep fixed_version
386 # Keep fixed_version
387 assert_equal 7, issue.fixed_version_id
387 assert_equal 7, issue.fixed_version_id
388 end
388 end
389
389
390 def test_move_to_another_project_with_disabled_tracker
390 def test_move_to_another_project_with_disabled_tracker
391 issue = Issue.find(1)
391 issue = Issue.find(1)
392 target = Project.find(2)
392 target = Project.find(2)
393 target.tracker_ids = [3]
393 target.tracker_ids = [3]
394 target.save
394 target.save
395 assert_equal false, issue.move_to_project(target)
395 assert_equal false, issue.move_to_project(target)
396 issue.reload
396 issue.reload
397 assert_equal 1, issue.project_id
397 assert_equal 1, issue.project_id
398 end
398 end
399
399
400 def test_copy_to_the_same_project
400 def test_copy_to_the_same_project
401 issue = Issue.find(1)
401 issue = Issue.find(1)
402 copy = nil
402 copy = nil
403 assert_difference 'Issue.count' do
403 assert_difference 'Issue.count' do
404 copy = issue.move_to_project(issue.project, nil, :copy => true)
404 copy = issue.move_to_project(issue.project, nil, :copy => true)
405 end
405 end
406 assert_kind_of Issue, copy
406 assert_kind_of Issue, copy
407 assert_equal issue.project, copy.project
407 assert_equal issue.project, copy.project
408 assert_equal "125", copy.custom_value_for(2).value
408 assert_equal "125", copy.custom_value_for(2).value
409 end
409 end
410
410
411 def test_copy_to_another_project_and_tracker
411 def test_copy_to_another_project_and_tracker
412 issue = Issue.find(1)
412 issue = Issue.find(1)
413 copy = nil
413 copy = nil
414 assert_difference 'Issue.count' do
414 assert_difference 'Issue.count' do
415 copy = issue.move_to_project(Project.find(3), Tracker.find(2), :copy => true)
415 copy = issue.move_to_project(Project.find(3), Tracker.find(2), :copy => true)
416 end
416 end
417 copy.reload
417 copy.reload
418 assert_kind_of Issue, copy
418 assert_kind_of Issue, copy
419 assert_equal Project.find(3), copy.project
419 assert_equal Project.find(3), copy.project
420 assert_equal Tracker.find(2), copy.tracker
420 assert_equal Tracker.find(2), copy.tracker
421 # Custom field #2 is not associated with target tracker
421 # Custom field #2 is not associated with target tracker
422 assert_nil copy.custom_value_for(2)
422 assert_nil copy.custom_value_for(2)
423 end
423 end
424
424
425 context "#move_to_project" do
425 context "#move_to_project" do
426 context "as a copy" do
426 context "as a copy" do
427 setup do
427 setup do
428 @issue = Issue.find(1)
428 @issue = Issue.find(1)
429 @copy = nil
429 @copy = nil
430 end
430 end
431
431
432 should "allow assigned_to changes" do
432 should "allow assigned_to changes" do
433 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:assigned_to_id => 3}})
433 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:assigned_to_id => 3}})
434 assert_equal 3, @copy.assigned_to_id
434 assert_equal 3, @copy.assigned_to_id
435 end
435 end
436
436
437 should "allow status changes" do
437 should "allow status changes" do
438 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:status_id => 2}})
438 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:status_id => 2}})
439 assert_equal 2, @copy.status_id
439 assert_equal 2, @copy.status_id
440 end
440 end
441
441
442 should "allow start date changes" do
442 should "allow start date changes" do
443 date = Date.today
443 date = Date.today
444 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:start_date => date}})
444 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:start_date => date}})
445 assert_equal date, @copy.start_date
445 assert_equal date, @copy.start_date
446 end
446 end
447
447
448 should "allow due date changes" do
448 should "allow due date changes" do
449 date = Date.today
449 date = Date.today
450 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:due_date => date}})
450 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:due_date => date}})
451
451
452 assert_equal date, @copy.due_date
452 assert_equal date, @copy.due_date
453 end
453 end
454 end
454 end
455 end
455 end
456
456
457 def test_recipients_should_not_include_users_that_cannot_view_the_issue
457 def test_recipients_should_not_include_users_that_cannot_view_the_issue
458 issue = Issue.find(12)
458 issue = Issue.find(12)
459 assert issue.recipients.include?(issue.author.mail)
459 assert issue.recipients.include?(issue.author.mail)
460 # move the issue to a private project
460 # move the issue to a private project
461 copy = issue.move_to_project(Project.find(5), Tracker.find(2), :copy => true)
461 copy = issue.move_to_project(Project.find(5), Tracker.find(2), :copy => true)
462 # author is not a member of project anymore
462 # author is not a member of project anymore
463 assert !copy.recipients.include?(copy.author.mail)
463 assert !copy.recipients.include?(copy.author.mail)
464 end
464 end
465
465
466 def test_watcher_recipients_should_not_include_users_that_cannot_view_the_issue
466 def test_watcher_recipients_should_not_include_users_that_cannot_view_the_issue
467 user = User.find(3)
467 user = User.find(3)
468 issue = Issue.find(9)
468 issue = Issue.find(9)
469 Watcher.create!(:user => user, :watchable => issue)
469 Watcher.create!(:user => user, :watchable => issue)
470 assert issue.watched_by?(user)
470 assert issue.watched_by?(user)
471 assert !issue.watcher_recipients.include?(user.mail)
471 assert !issue.watcher_recipients.include?(user.mail)
472 end
472 end
473
473
474 def test_issue_destroy
474 def test_issue_destroy
475 Issue.find(1).destroy
475 Issue.find(1).destroy
476 assert_nil Issue.find_by_id(1)
476 assert_nil Issue.find_by_id(1)
477 assert_nil TimeEntry.find_by_issue_id(1)
477 assert_nil TimeEntry.find_by_issue_id(1)
478 end
478 end
479
479
480 def test_blocked
480 def test_blocked
481 blocked_issue = Issue.find(9)
481 blocked_issue = Issue.find(9)
482 blocking_issue = Issue.find(10)
482 blocking_issue = Issue.find(10)
483
483
484 assert blocked_issue.blocked?
484 assert blocked_issue.blocked?
485 assert !blocking_issue.blocked?
485 assert !blocking_issue.blocked?
486 end
486 end
487
487
488 def test_blocked_issues_dont_allow_closed_statuses
488 def test_blocked_issues_dont_allow_closed_statuses
489 blocked_issue = Issue.find(9)
489 blocked_issue = Issue.find(9)
490
490
491 allowed_statuses = blocked_issue.new_statuses_allowed_to(users(:users_002))
491 allowed_statuses = blocked_issue.new_statuses_allowed_to(users(:users_002))
492 assert !allowed_statuses.empty?
492 assert !allowed_statuses.empty?
493 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
493 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
494 assert closed_statuses.empty?
494 assert closed_statuses.empty?
495 end
495 end
496
496
497 def test_unblocked_issues_allow_closed_statuses
497 def test_unblocked_issues_allow_closed_statuses
498 blocking_issue = Issue.find(10)
498 blocking_issue = Issue.find(10)
499
499
500 allowed_statuses = blocking_issue.new_statuses_allowed_to(users(:users_002))
500 allowed_statuses = blocking_issue.new_statuses_allowed_to(users(:users_002))
501 assert !allowed_statuses.empty?
501 assert !allowed_statuses.empty?
502 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
502 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
503 assert !closed_statuses.empty?
503 assert !closed_statuses.empty?
504 end
504 end
505
505
506 def test_overdue
506 def test_overdue
507 assert Issue.new(:due_date => 1.day.ago.to_date).overdue?
507 assert Issue.new(:due_date => 1.day.ago.to_date).overdue?
508 assert !Issue.new(:due_date => Date.today).overdue?
508 assert !Issue.new(:due_date => Date.today).overdue?
509 assert !Issue.new(:due_date => 1.day.from_now.to_date).overdue?
509 assert !Issue.new(:due_date => 1.day.from_now.to_date).overdue?
510 assert !Issue.new(:due_date => nil).overdue?
510 assert !Issue.new(:due_date => nil).overdue?
511 assert !Issue.new(:due_date => 1.day.ago.to_date, :status => IssueStatus.find(:first, :conditions => {:is_closed => true})).overdue?
511 assert !Issue.new(:due_date => 1.day.ago.to_date, :status => IssueStatus.find(:first, :conditions => {:is_closed => true})).overdue?
512 end
512 end
513
513
514 def test_assignable_users
514 def test_assignable_users
515 assert_kind_of User, Issue.find(1).assignable_users.first
515 assert_kind_of User, Issue.find(1).assignable_users.first
516 end
516 end
517
517
518 def test_create_should_send_email_notification
518 def test_create_should_send_email_notification
519 ActionMailer::Base.deliveries.clear
519 ActionMailer::Base.deliveries.clear
520 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :estimated_hours => '1:30')
520 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :estimated_hours => '1:30')
521
521
522 assert issue.save
522 assert issue.save
523 assert_equal 1, ActionMailer::Base.deliveries.size
523 assert_equal 1, ActionMailer::Base.deliveries.size
524 end
524 end
525
525
526 def test_stale_issue_should_not_send_email_notification
526 def test_stale_issue_should_not_send_email_notification
527 ActionMailer::Base.deliveries.clear
527 ActionMailer::Base.deliveries.clear
528 issue = Issue.find(1)
528 issue = Issue.find(1)
529 stale = Issue.find(1)
529 stale = Issue.find(1)
530
530
531 issue.init_journal(User.find(1))
531 issue.init_journal(User.find(1))
532 issue.subject = 'Subjet update'
532 issue.subject = 'Subjet update'
533 assert issue.save
533 assert issue.save
534 assert_equal 1, ActionMailer::Base.deliveries.size
534 assert_equal 1, ActionMailer::Base.deliveries.size
535 ActionMailer::Base.deliveries.clear
535 ActionMailer::Base.deliveries.clear
536
536
537 stale.init_journal(User.find(1))
537 stale.init_journal(User.find(1))
538 stale.subject = 'Another subjet update'
538 stale.subject = 'Another subjet update'
539 assert_raise ActiveRecord::StaleObjectError do
539 assert_raise ActiveRecord::StaleObjectError do
540 stale.save
540 stale.save
541 end
541 end
542 assert ActionMailer::Base.deliveries.empty?
542 assert ActionMailer::Base.deliveries.empty?
543 end
543 end
544
544
545 def test_saving_twice_should_not_duplicate_journal_details
545 def test_saving_twice_should_not_duplicate_journal_details
546 i = Issue.find(:first)
546 i = Issue.find(:first)
547 i.init_journal(User.find(2), 'Some notes')
547 i.init_journal(User.find(2), 'Some notes')
548 # initial changes
548 # initial changes
549 i.subject = 'New subject'
549 i.subject = 'New subject'
550 i.done_ratio = i.done_ratio + 10
550 i.done_ratio = i.done_ratio + 10
551 assert_difference 'Journal.count' do
551 assert_difference 'Journal.count' do
552 assert i.save
552 assert i.save
553 end
553 end
554 # 1 more change
554 # 1 more change
555 i.priority = IssuePriority.find(:first, :conditions => ["id <> ?", i.priority_id])
555 i.priority = IssuePriority.find(:first, :conditions => ["id <> ?", i.priority_id])
556 assert_no_difference 'Journal.count' do
556 assert_no_difference 'Journal.count' do
557 assert_difference 'JournalDetail.count', 1 do
557 assert_difference 'JournalDetail.count', 1 do
558 i.save
558 i.save
559 end
559 end
560 end
560 end
561 # no more change
561 # no more change
562 assert_no_difference 'Journal.count' do
562 assert_no_difference 'Journal.count' do
563 assert_no_difference 'JournalDetail.count' do
563 assert_no_difference 'JournalDetail.count' do
564 i.save
564 i.save
565 end
565 end
566 end
566 end
567 end
567 end
568
568
569 context "#done_ratio" do
569 context "#done_ratio" do
570 setup do
570 setup do
571 @issue = Issue.find(1)
571 @issue = Issue.find(1)
572 @issue_status = IssueStatus.find(1)
572 @issue_status = IssueStatus.find(1)
573 @issue_status.update_attribute(:default_done_ratio, 50)
573 @issue_status.update_attribute(:default_done_ratio, 50)
574 end
574 end
575
575
576 context "with Setting.issue_done_ratio using the issue_field" do
576 context "with Setting.issue_done_ratio using the issue_field" do
577 setup do
577 setup do
578 Setting.issue_done_ratio = 'issue_field'
578 Setting.issue_done_ratio = 'issue_field'
579 end
579 end
580
580
581 should "read the issue's field" do
581 should "read the issue's field" do
582 assert_equal 0, @issue.done_ratio
582 assert_equal 0, @issue.done_ratio
583 end
583 end
584 end
584 end
585
585
586 context "with Setting.issue_done_ratio using the issue_status" do
586 context "with Setting.issue_done_ratio using the issue_status" do
587 setup do
587 setup do
588 Setting.issue_done_ratio = 'issue_status'
588 Setting.issue_done_ratio = 'issue_status'
589 end
589 end
590
590
591 should "read the Issue Status's default done ratio" do
591 should "read the Issue Status's default done ratio" do
592 assert_equal 50, @issue.done_ratio
592 assert_equal 50, @issue.done_ratio
593 end
593 end
594 end
594 end
595 end
595 end
596
596
597 context "#update_done_ratio_from_issue_status" do
597 context "#update_done_ratio_from_issue_status" do
598 setup do
598 setup do
599 @issue = Issue.find(1)
599 @issue = Issue.find(1)
600 @issue_status = IssueStatus.find(1)
600 @issue_status = IssueStatus.find(1)
601 @issue_status.update_attribute(:default_done_ratio, 50)
601 @issue_status.update_attribute(:default_done_ratio, 50)
602 end
602 end
603
603
604 context "with Setting.issue_done_ratio using the issue_field" do
604 context "with Setting.issue_done_ratio using the issue_field" do
605 setup do
605 setup do
606 Setting.issue_done_ratio = 'issue_field'
606 Setting.issue_done_ratio = 'issue_field'
607 end
607 end
608
608
609 should "not change the issue" do
609 should "not change the issue" do
610 @issue.update_done_ratio_from_issue_status
610 @issue.update_done_ratio_from_issue_status
611
611
612 assert_equal 0, @issue.done_ratio
612 assert_equal 0, @issue.done_ratio
613 end
613 end
614 end
614 end
615
615
616 context "with Setting.issue_done_ratio using the issue_status" do
616 context "with Setting.issue_done_ratio using the issue_status" do
617 setup do
617 setup do
618 Setting.issue_done_ratio = 'issue_status'
618 Setting.issue_done_ratio = 'issue_status'
619 end
619 end
620
620
621 should "not change the issue's done ratio" do
621 should "not change the issue's done ratio" do
622 @issue.update_done_ratio_from_issue_status
622 @issue.update_done_ratio_from_issue_status
623
623
624 assert_equal 50, @issue.done_ratio
624 assert_equal 50, @issue.done_ratio
625 end
625 end
626 end
626 end
627 end
627 end
628
628
629 test "#by_tracker" do
629 test "#by_tracker" do
630 groups = Issue.by_tracker(Project.find(1))
630 groups = Issue.by_tracker(Project.find(1))
631 assert_equal 3, groups.size
631 assert_equal 3, groups.size
632 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
632 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
633 end
633 end
634
634
635 test "#by_version" do
635 test "#by_version" do
636 groups = Issue.by_version(Project.find(1))
636 groups = Issue.by_version(Project.find(1))
637 assert_equal 3, groups.size
637 assert_equal 3, groups.size
638 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
638 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
639 end
639 end
640
640
641 test "#by_priority" do
641 test "#by_priority" do
642 groups = Issue.by_priority(Project.find(1))
642 groups = Issue.by_priority(Project.find(1))
643 assert_equal 4, groups.size
643 assert_equal 4, groups.size
644 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
644 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
645 end
645 end
646
646
647 test "#by_category" do
647 test "#by_category" do
648 groups = Issue.by_category(Project.find(1))
648 groups = Issue.by_category(Project.find(1))
649 assert_equal 2, groups.size
649 assert_equal 2, groups.size
650 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
650 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
651 end
651 end
652
652
653 test "#by_assigned_to" do
653 test "#by_assigned_to" do
654 groups = Issue.by_assigned_to(Project.find(1))
654 groups = Issue.by_assigned_to(Project.find(1))
655 assert_equal 2, groups.size
655 assert_equal 2, groups.size
656 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
656 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
657 end
657 end
658
658
659 test "#by_author" do
659 test "#by_author" do
660 groups = Issue.by_author(Project.find(1))
660 groups = Issue.by_author(Project.find(1))
661 assert_equal 4, groups.size
661 assert_equal 4, groups.size
662 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
662 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
663 end
663 end
664
664
665 test "#by_subproject" do
665 test "#by_subproject" do
666 groups = Issue.by_subproject(Project.find(1))
666 groups = Issue.by_subproject(Project.find(1))
667 assert_equal 2, groups.size
667 assert_equal 2, groups.size
668 assert_equal 5, groups.inject(0) {|sum, group| sum + group['total'].to_i}
668 assert_equal 5, groups.inject(0) {|sum, group| sum + group['total'].to_i}
669 end
669 end
670
671
672 context ".allowed_target_projects_on_move" do
673 should "return all active projects for admin users" do
674 User.current = User.find(1)
675 assert_equal Project.active.count, Issue.allowed_target_projects_on_move.size
676 end
677
678 should "return allowed projects for non admin users" do
679 User.current = User.find(2)
680 Role.non_member.remove_permission! :move_issues
681 assert_equal 3, Issue.allowed_target_projects_on_move.size
682
683 Role.non_member.add_permission! :move_issues
684 assert_equal Project.active.count, Issue.allowed_target_projects_on_move.size
685 end
686 end
670
687
671 def test_recently_updated_with_limit_scopes
688 def test_recently_updated_with_limit_scopes
672 #should return the last updated issue
689 #should return the last updated issue
673 assert_equal 1, Issue.recently_updated.with_limit(1).length
690 assert_equal 1, Issue.recently_updated.with_limit(1).length
674 assert_equal Issue.find(:first, :order => "updated_on DESC"), Issue.recently_updated.with_limit(1).first
691 assert_equal Issue.find(:first, :order => "updated_on DESC"), Issue.recently_updated.with_limit(1).first
675 end
692 end
676
693
677 def test_on_active_projects_scope
694 def test_on_active_projects_scope
678 assert Project.find(2).archive
695 assert Project.find(2).archive
679
696
680 before = Issue.on_active_project.length
697 before = Issue.on_active_project.length
681 # test inclusion to results
698 # test inclusion to results
682 issue = Issue.generate_for_project!(Project.find(1), :tracker => Project.find(2).trackers.first)
699 issue = Issue.generate_for_project!(Project.find(1), :tracker => Project.find(2).trackers.first)
683 assert_equal before + 1, Issue.on_active_project.length
700 assert_equal before + 1, Issue.on_active_project.length
684
701
685 # Move to an archived project
702 # Move to an archived project
686 issue.project = Project.find(2)
703 issue.project = Project.find(2)
687 assert issue.save
704 assert issue.save
688 assert_equal before, Issue.on_active_project.length
705 assert_equal before, Issue.on_active_project.length
689 end
706 end
690 end
707 end
General Comments 0
You need to be logged in to leave comments. Login now