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