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