##// END OF EJS Templates
Added Issue#attributes_editable?...
Jean-Philippe Lang -
r13614:37aa01674039
parent child
Show More
@@ -1,519 +1,517
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2015 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, :destroy]
24 24 before_filter :find_project, :only => [:new, :create, :update_form]
25 25 before_filter :authorize, :except => [:index]
26 26 before_filter :find_optional_project, :only => [:index]
27 27 before_filter :build_new_issue_from_params, :only => [:new, :create, :update_form]
28 28 accept_rss_auth :index, :show
29 29 accept_api_auth :index, :show, :create, :update, :destroy
30 30
31 31 rescue_from Query::StatementInvalid, :with => :query_statement_invalid
32 32
33 33 helper :journals
34 34 helper :projects
35 35 include ProjectsHelper
36 36 helper :custom_fields
37 37 include CustomFieldsHelper
38 38 helper :issue_relations
39 39 include IssueRelationsHelper
40 40 helper :watchers
41 41 include WatchersHelper
42 42 helper :attachments
43 43 include AttachmentsHelper
44 44 helper :queries
45 45 include QueriesHelper
46 46 helper :repositories
47 47 include RepositoriesHelper
48 48 helper :sort
49 49 include SortHelper
50 50 include IssuesHelper
51 51 helper :timelog
52 52
53 53 def index
54 54 retrieve_query
55 55 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
56 56 sort_update(@query.sortable_columns)
57 57 @query.sort_criteria = sort_criteria.to_a
58 58
59 59 if @query.valid?
60 60 case params[:format]
61 61 when 'csv', 'pdf'
62 62 @limit = Setting.issues_export_limit.to_i
63 63 if params[:columns] == 'all'
64 64 @query.column_names = @query.available_inline_columns.map(&:name)
65 65 end
66 66 when 'atom'
67 67 @limit = Setting.feeds_limit.to_i
68 68 when 'xml', 'json'
69 69 @offset, @limit = api_offset_and_limit
70 70 @query.column_names = %w(author)
71 71 else
72 72 @limit = per_page_option
73 73 end
74 74
75 75 @issue_count = @query.issue_count
76 76 @issue_pages = Paginator.new @issue_count, @limit, params['page']
77 77 @offset ||= @issue_pages.offset
78 78 @issues = @query.issues(:include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
79 79 :order => sort_clause,
80 80 :offset => @offset,
81 81 :limit => @limit)
82 82 @issue_count_by_group = @query.issue_count_by_group
83 83
84 84 respond_to do |format|
85 85 format.html { render :template => 'issues/index', :layout => !request.xhr? }
86 86 format.api {
87 87 Issue.load_visible_relations(@issues) if include_in_api_response?('relations')
88 88 }
89 89 format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
90 90 format.csv { send_data(query_to_csv(@issues, @query, params), :type => 'text/csv; header=present', :filename => 'issues.csv') }
91 91 format.pdf { send_file_headers! :type => 'application/pdf', :filename => 'issues.pdf' }
92 92 end
93 93 else
94 94 respond_to do |format|
95 95 format.html { render(:template => 'issues/index', :layout => !request.xhr?) }
96 96 format.any(:atom, :csv, :pdf) { render(:nothing => true) }
97 97 format.api { render_validation_errors(@query) }
98 98 end
99 99 end
100 100 rescue ActiveRecord::RecordNotFound
101 101 render_404
102 102 end
103 103
104 104 def show
105 105 @journals = @issue.journals.includes(:user, :details).
106 106 references(:user, :details).
107 107 reorder("#{Journal.table_name}.id ASC").to_a
108 108 @journals.each_with_index {|j,i| j.indice = i+1}
109 109 @journals.reject!(&:private_notes?) unless User.current.allowed_to?(:view_private_notes, @issue.project)
110 110 Journal.preload_journals_details_custom_fields(@journals)
111 111 @journals.select! {|journal| journal.notes? || journal.visible_details.any?}
112 112 @journals.reverse! if User.current.wants_comments_in_reverse_order?
113 113
114 114 @changesets = @issue.changesets.visible.to_a
115 115 @changesets.reverse! if User.current.wants_comments_in_reverse_order?
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 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
120 119 @priorities = IssuePriority.active
121 120 @time_entry = TimeEntry.new(:issue => @issue, :project => @issue.project)
122 121 @relation = IssueRelation.new
123 122
124 123 respond_to do |format|
125 124 format.html {
126 125 retrieve_previous_and_next_issue_ids
127 126 render :template => 'issues/show'
128 127 }
129 128 format.api
130 129 format.atom { render :template => 'journals/index', :layout => false, :content_type => 'application/atom+xml' }
131 130 format.pdf {
132 131 send_file_headers! :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf"
133 132 }
134 133 end
135 134 end
136 135
137 136 # Add a new issue
138 137 # The new issue will be created from an existing one if copy_from parameter is given
139 138 def new
140 139 respond_to do |format|
141 140 format.html { render :action => 'new', :layout => !request.xhr? }
142 141 end
143 142 end
144 143
145 144 def create
146 145 unless User.current.allowed_to?(:add_issues, @issue.project)
147 146 raise ::Unauthorized
148 147 end
149 148 call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
150 149 @issue.save_attachments(params[:attachments] || (params[:issue] && params[:issue][:uploads]))
151 150 if @issue.save
152 151 call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
153 152 respond_to do |format|
154 153 format.html {
155 154 render_attachment_warning_if_needed(@issue)
156 155 flash[:notice] = l(:notice_issue_successful_create, :id => view_context.link_to("##{@issue.id}", issue_path(@issue), :title => @issue.subject))
157 156 if params[:continue]
158 157 attrs = {:tracker_id => @issue.tracker, :parent_issue_id => @issue.parent_issue_id}.reject {|k,v| v.nil?}
159 158 redirect_to new_project_issue_path(@issue.project, :issue => attrs)
160 159 else
161 160 redirect_to issue_path(@issue)
162 161 end
163 162 }
164 163 format.api { render :action => 'show', :status => :created, :location => issue_url(@issue) }
165 164 end
166 165 return
167 166 else
168 167 respond_to do |format|
169 168 format.html { render :action => 'new' }
170 169 format.api { render_validation_errors(@issue) }
171 170 end
172 171 end
173 172 end
174 173
175 174 def edit
176 175 return unless update_issue_from_params
177 176
178 177 respond_to do |format|
179 178 format.html { }
180 179 format.xml { }
181 180 end
182 181 end
183 182
184 183 def update
185 184 return unless update_issue_from_params
186 185 @issue.save_attachments(params[:attachments] || (params[:issue] && params[:issue][:uploads]))
187 186 saved = false
188 187 begin
189 188 saved = save_issue_with_child_records
190 189 rescue ActiveRecord::StaleObjectError
191 190 @conflict = true
192 191 if params[:last_journal_id]
193 192 @conflict_journals = @issue.journals_after(params[:last_journal_id]).to_a
194 193 @conflict_journals.reject!(&:private_notes?) unless User.current.allowed_to?(:view_private_notes, @issue.project)
195 194 end
196 195 end
197 196
198 197 if saved
199 198 render_attachment_warning_if_needed(@issue)
200 199 flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
201 200
202 201 respond_to do |format|
203 202 format.html { redirect_back_or_default issue_path(@issue) }
204 203 format.api { render_api_ok }
205 204 end
206 205 else
207 206 respond_to do |format|
208 207 format.html { render :action => 'edit' }
209 208 format.api { render_validation_errors(@issue) }
210 209 end
211 210 end
212 211 end
213 212
214 213 # Updates the issue form when changing the project, status or tracker
215 214 # on issue creation/update
216 215 def update_form
217 216 end
218 217
219 218 # Bulk edit/copy a set of issues
220 219 def bulk_edit
221 220 @issues.sort!
222 221 @copy = params[:copy].present?
223 222 @notes = params[:notes]
224 223
225 224 if @copy
226 225 unless User.current.allowed_to?(:copy_issues, @projects)
227 226 raise ::Unauthorized
228 227 end
229 228 end
230 229
231 230 @allowed_projects = Issue.allowed_target_projects
232 231 if params[:issue]
233 232 @target_project = @allowed_projects.detect {|p| p.id.to_s == params[:issue][:project_id].to_s}
234 233 if @target_project
235 234 target_projects = [@target_project]
236 235 end
237 236 end
238 237 target_projects ||= @projects
239 238
240 239 if @copy
241 240 # Copied issues will get their default statuses
242 241 @available_statuses = []
243 242 else
244 243 @available_statuses = @issues.map(&:new_statuses_allowed_to).reduce(:&)
245 244 end
246 245 @custom_fields = target_projects.map{|p|p.all_issue_custom_fields.visible}.reduce(:&)
247 246 @assignables = target_projects.map(&:assignable_users).reduce(:&)
248 247 @trackers = target_projects.map(&:trackers).reduce(:&)
249 248 @versions = target_projects.map {|p| p.shared_versions.open}.reduce(:&)
250 249 @categories = target_projects.map {|p| p.issue_categories}.reduce(:&)
251 250 if @copy
252 251 @attachments_present = @issues.detect {|i| i.attachments.any?}.present?
253 252 @subtasks_present = @issues.detect {|i| !i.leaf?}.present?
254 253 end
255 254
256 255 @safe_attributes = @issues.map(&:safe_attribute_names).reduce(:&)
257 256
258 257 @issue_params = params[:issue] || {}
259 258 @issue_params[:custom_field_values] ||= {}
260 259 end
261 260
262 261 def bulk_update
263 262 @issues.sort!
264 263 @copy = params[:copy].present?
265 264 attributes = parse_params_for_bulk_issue_attributes(params)
266 265
267 266 if @copy
268 267 unless User.current.allowed_to?(:copy_issues, @projects)
269 268 raise ::Unauthorized
270 269 end
271 270 target_projects = @projects
272 271 if attributes['project_id'].present?
273 272 target_projects = Project.where(:id => attributes['project_id']).to_a
274 273 end
275 274 unless User.current.allowed_to?(:add_issues, target_projects)
276 275 raise ::Unauthorized
277 276 end
278 277 end
279 278
280 279 unsaved_issues = []
281 280 saved_issues = []
282 281
283 282 if @copy && params[:copy_subtasks].present?
284 283 # Descendant issues will be copied with the parent task
285 284 # Don't copy them twice
286 285 @issues.reject! {|issue| @issues.detect {|other| issue.is_descendant_of?(other)}}
287 286 end
288 287
289 288 @issues.each do |orig_issue|
290 289 orig_issue.reload
291 290 if @copy
292 291 issue = orig_issue.copy({},
293 292 :attachments => params[:copy_attachments].present?,
294 293 :subtasks => params[:copy_subtasks].present?,
295 294 :link => link_copy?(params[:link_copy])
296 295 )
297 296 else
298 297 issue = orig_issue
299 298 end
300 299 journal = issue.init_journal(User.current, params[:notes])
301 300 issue.safe_attributes = attributes
302 301 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
303 302 if issue.save
304 303 saved_issues << issue
305 304 else
306 305 unsaved_issues << orig_issue
307 306 end
308 307 end
309 308
310 309 if unsaved_issues.empty?
311 310 flash[:notice] = l(:notice_successful_update) unless saved_issues.empty?
312 311 if params[:follow]
313 312 if @issues.size == 1 && saved_issues.size == 1
314 313 redirect_to issue_path(saved_issues.first)
315 314 elsif saved_issues.map(&:project).uniq.size == 1
316 315 redirect_to project_issues_path(saved_issues.map(&:project).first)
317 316 end
318 317 else
319 318 redirect_back_or_default _project_issues_path(@project)
320 319 end
321 320 else
322 321 @saved_issues = @issues
323 322 @unsaved_issues = unsaved_issues
324 323 @issues = Issue.visible.where(:id => @unsaved_issues.map(&:id)).to_a
325 324 bulk_edit
326 325 render :action => 'bulk_edit'
327 326 end
328 327 end
329 328
330 329 def destroy
331 330 @hours = TimeEntry.where(:issue_id => @issues.map(&:id)).sum(:hours).to_f
332 331 if @hours > 0
333 332 case params[:todo]
334 333 when 'destroy'
335 334 # nothing to do
336 335 when 'nullify'
337 336 TimeEntry.where(['issue_id IN (?)', @issues]).update_all('issue_id = NULL')
338 337 when 'reassign'
339 338 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
340 339 if reassign_to.nil?
341 340 flash.now[:error] = l(:error_issue_not_found_in_project)
342 341 return
343 342 else
344 343 TimeEntry.where(['issue_id IN (?)', @issues]).
345 344 update_all("issue_id = #{reassign_to.id}")
346 345 end
347 346 else
348 347 # display the destroy form if it's a user request
349 348 return unless api_request?
350 349 end
351 350 end
352 351 @issues.each do |issue|
353 352 begin
354 353 issue.reload.destroy
355 354 rescue ::ActiveRecord::RecordNotFound # raised by #reload if issue no longer exists
356 355 # nothing to do, issue was already deleted (eg. by a parent)
357 356 end
358 357 end
359 358 respond_to do |format|
360 359 format.html { redirect_back_or_default _project_issues_path(@project) }
361 360 format.api { render_api_ok }
362 361 end
363 362 end
364 363
365 364 private
366 365
367 366 def find_project
368 367 project_id = params[:project_id] || (params[:issue] && params[:issue][:project_id])
369 368 @project = Project.find(project_id)
370 369 rescue ActiveRecord::RecordNotFound
371 370 render_404
372 371 end
373 372
374 373 def retrieve_previous_and_next_issue_ids
375 374 retrieve_query_from_session
376 375 if @query
377 376 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
378 377 sort_update(@query.sortable_columns, 'issues_index_sort')
379 378 limit = 500
380 379 issue_ids = @query.issue_ids(:order => sort_clause, :limit => (limit + 1), :include => [:assigned_to, :tracker, :priority, :category, :fixed_version])
381 380 if (idx = issue_ids.index(@issue.id)) && idx < limit
382 381 if issue_ids.size < 500
383 382 @issue_position = idx + 1
384 383 @issue_count = issue_ids.size
385 384 end
386 385 @prev_issue_id = issue_ids[idx - 1] if idx > 0
387 386 @next_issue_id = issue_ids[idx + 1] if idx < (issue_ids.size - 1)
388 387 end
389 388 end
390 389 end
391 390
392 391 # Used by #edit and #update to set some common instance variables
393 392 # from the params
394 393 # TODO: Refactor, not everything in here is needed by #edit
395 394 def update_issue_from_params
396 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
397 395 @time_entry = TimeEntry.new(:issue => @issue, :project => @issue.project)
398 396 if params[:time_entry]
399 397 @time_entry.attributes = params[:time_entry]
400 398 end
401 399
402 400 @issue.init_journal(User.current)
403 401
404 402 issue_attributes = params[:issue]
405 403 if issue_attributes && params[:conflict_resolution]
406 404 case params[:conflict_resolution]
407 405 when 'overwrite'
408 406 issue_attributes = issue_attributes.dup
409 407 issue_attributes.delete(:lock_version)
410 408 when 'add_notes'
411 409 issue_attributes = issue_attributes.slice(:notes)
412 410 when 'cancel'
413 411 redirect_to issue_path(@issue)
414 412 return false
415 413 end
416 414 end
417 415 @issue.safe_attributes = issue_attributes
418 416 @priorities = IssuePriority.active
419 417 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
420 418 true
421 419 end
422 420
423 421 # TODO: Refactor, lots of extra code in here
424 422 # TODO: Changing tracker on an existing issue should not trigger this
425 423 def build_new_issue_from_params
426 424 if params[:id].blank?
427 425 @issue = Issue.new
428 426 if params[:copy_from]
429 427 begin
430 428 @issue.init_journal(User.current)
431 429 @copy_from = Issue.visible.find(params[:copy_from])
432 430 unless User.current.allowed_to?(:copy_issues, @copy_from.project)
433 431 raise ::Unauthorized
434 432 end
435 433 @link_copy = link_copy?(params[:link_copy]) || request.get?
436 434 @copy_attachments = params[:copy_attachments].present? || request.get?
437 435 @copy_subtasks = params[:copy_subtasks].present? || request.get?
438 436 @issue.copy_from(@copy_from, :attachments => @copy_attachments, :subtasks => @copy_subtasks, :link => @link_copy)
439 437 rescue ActiveRecord::RecordNotFound
440 438 render_404
441 439 return
442 440 end
443 441 end
444 442 @issue.project = @project
445 443 @issue.author ||= User.current
446 444 @issue.start_date ||= Date.today if Setting.default_issue_start_date_to_creation_date?
447 445 else
448 446 @issue = @project.issues.visible.find(params[:id])
449 447 end
450 448
451 449 if attrs = params[:issue].deep_dup
452 450 if params[:was_default_status] == attrs[:status_id]
453 451 attrs.delete(:status_id)
454 452 end
455 453 @issue.safe_attributes = attrs
456 454 end
457 455 @issue.tracker ||= @project.trackers.first
458 456 if @issue.tracker.nil?
459 457 render_error l(:error_no_tracker_in_project)
460 458 return false
461 459 end
462 460 if @issue.status.nil?
463 461 render_error l(:error_no_default_issue_status)
464 462 return false
465 463 end
466 464
467 465 @priorities = IssuePriority.active
468 466 @allowed_statuses = @issue.new_statuses_allowed_to(User.current, @issue.new_record?)
469 467 end
470 468
471 469 def parse_params_for_bulk_issue_attributes(params)
472 470 attributes = (params[:issue] || {}).reject {|k,v| v.blank?}
473 471 attributes.keys.each {|k| attributes[k] = '' if attributes[k] == 'none'}
474 472 if custom = attributes[:custom_field_values]
475 473 custom.reject! {|k,v| v.blank?}
476 474 custom.keys.each do |k|
477 475 if custom[k].is_a?(Array)
478 476 custom[k] << '' if custom[k].delete('__none__')
479 477 else
480 478 custom[k] = '' if custom[k] == '__none__'
481 479 end
482 480 end
483 481 end
484 482 attributes
485 483 end
486 484
487 485 # Saves @issue and a time_entry from the parameters
488 486 def save_issue_with_child_records
489 487 Issue.transaction do
490 488 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, @issue.project)
491 489 time_entry = @time_entry || TimeEntry.new
492 490 time_entry.project = @issue.project
493 491 time_entry.issue = @issue
494 492 time_entry.user = User.current
495 493 time_entry.spent_on = User.current.today
496 494 time_entry.attributes = params[:time_entry]
497 495 @issue.time_entries << time_entry
498 496 end
499 497
500 498 call_hook(:controller_issues_edit_before_save, { :params => params, :issue => @issue, :time_entry => time_entry, :journal => @issue.current_journal})
501 499 if @issue.save
502 500 call_hook(:controller_issues_edit_after_save, { :params => params, :issue => @issue, :time_entry => time_entry, :journal => @issue.current_journal})
503 501 else
504 502 raise ActiveRecord::Rollback
505 503 end
506 504 end
507 505 end
508 506
509 507 def link_copy?(param)
510 508 case Setting.link_copied_issue
511 509 when 'yes'
512 510 true
513 511 when 'no'
514 512 false
515 513 when 'ask'
516 514 param == '1'
517 515 end
518 516 end
519 517 end
@@ -1,1565 +1,1570
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2015 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 include Redmine::Utils::DateCalculation
21 21 include Redmine::I18n
22 22 before_save :set_parent_id
23 23 include Redmine::NestedSet::IssueNestedSet
24 24
25 25 belongs_to :project
26 26 belongs_to :tracker
27 27 belongs_to :status, :class_name => 'IssueStatus'
28 28 belongs_to :author, :class_name => 'User'
29 29 belongs_to :assigned_to, :class_name => 'Principal'
30 30 belongs_to :fixed_version, :class_name => 'Version'
31 31 belongs_to :priority, :class_name => 'IssuePriority'
32 32 belongs_to :category, :class_name => 'IssueCategory'
33 33
34 34 has_many :journals, :as => :journalized, :dependent => :destroy, :inverse_of => :journalized
35 35 has_many :visible_journals,
36 36 lambda {where(["(#{Journal.table_name}.private_notes = ? OR (#{Project.allowed_to_condition(User.current, :view_private_notes)}))", false])},
37 37 :class_name => 'Journal',
38 38 :as => :journalized
39 39
40 40 has_many :time_entries, :dependent => :destroy
41 41 has_and_belongs_to_many :changesets, lambda {order("#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC")}
42 42
43 43 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
44 44 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
45 45
46 46 acts_as_attachable :after_add => :attachment_added, :after_remove => :attachment_removed
47 47 acts_as_customizable
48 48 acts_as_watchable
49 49 acts_as_searchable :columns => ['subject', "#{table_name}.description"],
50 50 :preload => [:project, :status, :tracker],
51 51 :scope => lambda {|options| options[:open_issues] ? self.open : self.all}
52 52
53 53 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
54 54 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
55 55 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
56 56
57 57 acts_as_activity_provider :scope => preload(:project, :author, :tracker),
58 58 :author_key => :author_id
59 59
60 60 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
61 61
62 62 attr_reader :current_journal
63 63 delegate :notes, :notes=, :private_notes, :private_notes=, :to => :current_journal, :allow_nil => true
64 64
65 65 validates_presence_of :subject, :project, :tracker
66 66 validates_presence_of :priority, :if => Proc.new {|issue| issue.new_record? || issue.priority_id_changed?}
67 67 validates_presence_of :status, :if => Proc.new {|issue| issue.new_record? || issue.status_id_changed?}
68 68 validates_presence_of :author, :if => Proc.new {|issue| issue.new_record? || issue.author_id_changed?}
69 69
70 70 validates_length_of :subject, :maximum => 255
71 71 validates_inclusion_of :done_ratio, :in => 0..100
72 72 validates :estimated_hours, :numericality => {:greater_than_or_equal_to => 0, :allow_nil => true, :message => :invalid}
73 73 validates :start_date, :date => true
74 74 validates :due_date, :date => true
75 75 validate :validate_issue, :validate_required_fields
76 76 attr_protected :id
77 77
78 78 scope :visible, lambda {|*args|
79 79 joins(:project).
80 80 where(Issue.visible_condition(args.shift || User.current, *args))
81 81 }
82 82
83 83 scope :open, lambda {|*args|
84 84 is_closed = args.size > 0 ? !args.first : false
85 85 joins(:status).
86 86 where("#{IssueStatus.table_name}.is_closed = ?", is_closed)
87 87 }
88 88
89 89 scope :recently_updated, lambda { order("#{Issue.table_name}.updated_on DESC") }
90 90 scope :on_active_project, lambda {
91 91 joins(:project).
92 92 where("#{Project.table_name}.status = ?", Project::STATUS_ACTIVE)
93 93 }
94 94 scope :fixed_version, lambda {|versions|
95 95 ids = [versions].flatten.compact.map {|v| v.is_a?(Version) ? v.id : v}
96 96 ids.any? ? where(:fixed_version_id => ids) : where('1=0')
97 97 }
98 98
99 99 before_create :default_assign
100 100 before_save :close_duplicates, :update_done_ratio_from_issue_status,
101 101 :force_updated_on_change, :update_closed_on, :set_assigned_to_was
102 102 after_save {|issue| issue.send :after_project_change if !issue.id_changed? && issue.project_id_changed?}
103 103 after_save :reschedule_following_issues, :update_nested_set_attributes,
104 104 :update_parent_attributes, :create_journal
105 105 # Should be after_create but would be called before previous after_save callbacks
106 106 after_save :after_create_from_copy
107 107 after_destroy :update_parent_attributes
108 108 after_create :send_notification
109 109 # Keep it at the end of after_save callbacks
110 110 after_save :clear_assigned_to_was
111 111
112 112 # Returns a SQL conditions string used to find all issues visible by the specified user
113 113 def self.visible_condition(user, options={})
114 114 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
115 115 if user.id && user.logged?
116 116 case role.issues_visibility
117 117 when 'all'
118 118 nil
119 119 when 'default'
120 120 user_ids = [user.id] + user.groups.map(&:id).compact
121 121 "(#{table_name}.is_private = #{connection.quoted_false} OR #{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
122 122 when 'own'
123 123 user_ids = [user.id] + user.groups.map(&:id).compact
124 124 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id IN (#{user_ids.join(',')}))"
125 125 else
126 126 '1=0'
127 127 end
128 128 else
129 129 "(#{table_name}.is_private = #{connection.quoted_false})"
130 130 end
131 131 end
132 132 end
133 133
134 134 # Returns true if usr or current user is allowed to view the issue
135 135 def visible?(usr=nil)
136 136 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
137 137 if user.logged?
138 138 case role.issues_visibility
139 139 when 'all'
140 140 true
141 141 when 'default'
142 142 !self.is_private? || (self.author == user || user.is_or_belongs_to?(assigned_to))
143 143 when 'own'
144 144 self.author == user || user.is_or_belongs_to?(assigned_to)
145 145 else
146 146 false
147 147 end
148 148 else
149 149 !self.is_private?
150 150 end
151 151 end
152 152 end
153 153
154 154 # Returns true if user or current user is allowed to edit or add a note to the issue
155 155 def editable?(user=User.current)
156 user.allowed_to?(:edit_issues, project) || user.allowed_to?(:add_issue_notes, project)
156 attributes_editable?(user) || user.allowed_to?(:add_issue_notes, project)
157 end
158
159 # Returns true if user or current user is allowed to edit the issue
160 def attributes_editable?(user=User.current)
161 user.allowed_to?(:edit_issues, project)
157 162 end
158 163
159 164 def initialize(attributes=nil, *args)
160 165 super
161 166 if new_record?
162 167 # set default values for new records only
163 168 self.priority ||= IssuePriority.default
164 169 self.watcher_user_ids = []
165 170 end
166 171 end
167 172
168 173 def create_or_update
169 174 super
170 175 ensure
171 176 @status_was = nil
172 177 end
173 178 private :create_or_update
174 179
175 180 # AR#Persistence#destroy would raise and RecordNotFound exception
176 181 # if the issue was already deleted or updated (non matching lock_version).
177 182 # This is a problem when bulk deleting issues or deleting a project
178 183 # (because an issue may already be deleted if its parent was deleted
179 184 # first).
180 185 # The issue is reloaded by the nested_set before being deleted so
181 186 # the lock_version condition should not be an issue but we handle it.
182 187 def destroy
183 188 super
184 189 rescue ActiveRecord::StaleObjectError, ActiveRecord::RecordNotFound
185 190 # Stale or already deleted
186 191 begin
187 192 reload
188 193 rescue ActiveRecord::RecordNotFound
189 194 # The issue was actually already deleted
190 195 @destroyed = true
191 196 return freeze
192 197 end
193 198 # The issue was stale, retry to destroy
194 199 super
195 200 end
196 201
197 202 alias :base_reload :reload
198 203 def reload(*args)
199 204 @workflow_rule_by_attribute = nil
200 205 @assignable_versions = nil
201 206 @relations = nil
202 207 @spent_hours = nil
203 208 base_reload(*args)
204 209 end
205 210
206 211 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
207 212 def available_custom_fields
208 213 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields) : []
209 214 end
210 215
211 216 def visible_custom_field_values(user=nil)
212 217 user_real = user || User.current
213 218 custom_field_values.select do |value|
214 219 value.custom_field.visible_by?(project, user_real)
215 220 end
216 221 end
217 222
218 223 # Copies attributes from another issue, arg can be an id or an Issue
219 224 def copy_from(arg, options={})
220 225 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
221 226 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
222 227 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
223 228 self.status = issue.status
224 229 self.author = User.current
225 230 unless options[:attachments] == false
226 231 self.attachments = issue.attachments.map do |attachement|
227 232 attachement.copy(:container => self)
228 233 end
229 234 end
230 235 @copied_from = issue
231 236 @copy_options = options
232 237 self
233 238 end
234 239
235 240 # Returns an unsaved copy of the issue
236 241 def copy(attributes=nil, copy_options={})
237 242 copy = self.class.new.copy_from(self, copy_options)
238 243 copy.attributes = attributes if attributes
239 244 copy
240 245 end
241 246
242 247 # Returns true if the issue is a copy
243 248 def copy?
244 249 @copied_from.present?
245 250 end
246 251
247 252 def status_id=(status_id)
248 253 if status_id.to_s != self.status_id.to_s
249 254 self.status = (status_id.present? ? IssueStatus.find_by_id(status_id) : nil)
250 255 end
251 256 self.status_id
252 257 end
253 258
254 259 # Sets the status.
255 260 def status=(status)
256 261 if status != self.status
257 262 @workflow_rule_by_attribute = nil
258 263 end
259 264 association(:status).writer(status)
260 265 end
261 266
262 267 def priority_id=(pid)
263 268 self.priority = nil
264 269 write_attribute(:priority_id, pid)
265 270 end
266 271
267 272 def category_id=(cid)
268 273 self.category = nil
269 274 write_attribute(:category_id, cid)
270 275 end
271 276
272 277 def fixed_version_id=(vid)
273 278 self.fixed_version = nil
274 279 write_attribute(:fixed_version_id, vid)
275 280 end
276 281
277 282 def tracker_id=(tracker_id)
278 283 if tracker_id.to_s != self.tracker_id.to_s
279 284 self.tracker = (tracker_id.present? ? Tracker.find_by_id(tracker_id) : nil)
280 285 end
281 286 self.tracker_id
282 287 end
283 288
284 289 # Sets the tracker.
285 290 # This will set the status to the default status of the new tracker if:
286 291 # * the status was the default for the previous tracker
287 292 # * or if the status was not part of the new tracker statuses
288 293 # * or the status was nil
289 294 def tracker=(tracker)
290 295 if tracker != self.tracker
291 296 if status == default_status
292 297 self.status = nil
293 298 elsif status && tracker && !tracker.issue_status_ids.include?(status.id)
294 299 self.status = nil
295 300 end
296 301 @custom_field_values = nil
297 302 @workflow_rule_by_attribute = nil
298 303 end
299 304 association(:tracker).writer(tracker)
300 305 self.status ||= default_status
301 306 self.tracker
302 307 end
303 308
304 309 def project_id=(project_id)
305 310 if project_id.to_s != self.project_id.to_s
306 311 self.project = (project_id.present? ? Project.find_by_id(project_id) : nil)
307 312 end
308 313 self.project_id
309 314 end
310 315
311 316 # Sets the project.
312 317 # Unless keep_tracker argument is set to true, this will change the tracker
313 318 # to the first tracker of the new project if the previous tracker is not part
314 319 # of the new project trackers.
315 320 # This will clear the fixed_version is it's no longer valid for the new project.
316 321 # This will clear the parent issue if it's no longer valid for the new project.
317 322 # This will set the category to the category with the same name in the new
318 323 # project if it exists, or clear it if it doesn't.
319 324 def project=(project, keep_tracker=false)
320 325 project_was = self.project
321 326 association(:project).writer(project)
322 327 if project_was && project && project_was != project
323 328 @assignable_versions = nil
324 329
325 330 unless keep_tracker || project.trackers.include?(tracker)
326 331 self.tracker = project.trackers.first
327 332 end
328 333 # Reassign to the category with same name if any
329 334 if category
330 335 self.category = project.issue_categories.find_by_name(category.name)
331 336 end
332 337 # Keep the fixed_version if it's still valid in the new_project
333 338 if fixed_version && fixed_version.project != project && !project.shared_versions.include?(fixed_version)
334 339 self.fixed_version = nil
335 340 end
336 341 # Clear the parent task if it's no longer valid
337 342 unless valid_parent_project?
338 343 self.parent_issue_id = nil
339 344 end
340 345 @custom_field_values = nil
341 346 @workflow_rule_by_attribute = nil
342 347 end
343 348 self.project
344 349 end
345 350
346 351 def description=(arg)
347 352 if arg.is_a?(String)
348 353 arg = arg.gsub(/(\r\n|\n|\r)/, "\r\n")
349 354 end
350 355 write_attribute(:description, arg)
351 356 end
352 357
353 358 # Overrides assign_attributes so that project and tracker get assigned first
354 359 def assign_attributes_with_project_and_tracker_first(new_attributes, *args)
355 360 return if new_attributes.nil?
356 361 attrs = new_attributes.dup
357 362 attrs.stringify_keys!
358 363
359 364 %w(project project_id tracker tracker_id).each do |attr|
360 365 if attrs.has_key?(attr)
361 366 send "#{attr}=", attrs.delete(attr)
362 367 end
363 368 end
364 369 send :assign_attributes_without_project_and_tracker_first, attrs, *args
365 370 end
366 371 # Do not redefine alias chain on reload (see #4838)
367 372 alias_method_chain(:assign_attributes, :project_and_tracker_first) unless method_defined?(:assign_attributes_without_project_and_tracker_first)
368 373
369 374 def attributes=(new_attributes)
370 375 assign_attributes new_attributes
371 376 end
372 377
373 378 def estimated_hours=(h)
374 379 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
375 380 end
376 381
377 382 safe_attributes 'project_id',
378 383 :if => lambda {|issue, user|
379 384 if issue.new_record?
380 385 issue.copy?
381 386 else
382 387 user.allowed_to?(:edit_issues, issue.project)
383 388 end
384 389 }
385 390
386 391 safe_attributes 'tracker_id',
387 392 'status_id',
388 393 'category_id',
389 394 'assigned_to_id',
390 395 'priority_id',
391 396 'fixed_version_id',
392 397 'subject',
393 398 'description',
394 399 'start_date',
395 400 'due_date',
396 401 'done_ratio',
397 402 'estimated_hours',
398 403 'custom_field_values',
399 404 'custom_fields',
400 405 'lock_version',
401 406 'notes',
402 407 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
403 408
404 409 safe_attributes 'notes',
405 410 :if => lambda {|issue, user| user.allowed_to?(:add_issue_notes, issue.project)}
406 411
407 412 safe_attributes 'private_notes',
408 413 :if => lambda {|issue, user| !issue.new_record? && user.allowed_to?(:set_notes_private, issue.project)}
409 414
410 415 safe_attributes 'watcher_user_ids',
411 416 :if => lambda {|issue, user| issue.new_record? && user.allowed_to?(:add_issue_watchers, issue.project)}
412 417
413 418 safe_attributes 'is_private',
414 419 :if => lambda {|issue, user|
415 420 user.allowed_to?(:set_issues_private, issue.project) ||
416 421 (issue.author_id == user.id && user.allowed_to?(:set_own_issues_private, issue.project))
417 422 }
418 423
419 424 safe_attributes 'parent_issue_id',
420 425 :if => lambda {|issue, user| (issue.new_record? || user.allowed_to?(:edit_issues, issue.project)) &&
421 426 user.allowed_to?(:manage_subtasks, issue.project)}
422 427
423 428 def safe_attribute_names(user=nil)
424 429 names = super
425 430 names -= disabled_core_fields
426 431 names -= read_only_attribute_names(user)
427 432 if new_record? && copy?
428 433 names |= %w(project_id)
429 434 end
430 435 names
431 436 end
432 437
433 438 # Safely sets attributes
434 439 # Should be called from controllers instead of #attributes=
435 440 # attr_accessible is too rough because we still want things like
436 441 # Issue.new(:project => foo) to work
437 442 def safe_attributes=(attrs, user=User.current)
438 443 return unless attrs.is_a?(Hash)
439 444
440 445 attrs = attrs.deep_dup
441 446
442 447 # Project and Tracker must be set before since new_statuses_allowed_to depends on it.
443 448 if (p = attrs.delete('project_id')) && safe_attribute?('project_id')
444 449 if allowed_target_projects(user).where(:id => p.to_i).exists?
445 450 self.project_id = p
446 451 end
447 452 end
448 453
449 454 if (t = attrs.delete('tracker_id')) && safe_attribute?('tracker_id')
450 455 self.tracker_id = t
451 456 end
452 457
453 458 if (s = attrs.delete('status_id')) && safe_attribute?('status_id')
454 459 if new_statuses_allowed_to(user).collect(&:id).include?(s.to_i)
455 460 self.status_id = s
456 461 end
457 462 end
458 463
459 464 attrs = delete_unsafe_attributes(attrs, user)
460 465 return if attrs.empty?
461 466
462 467 unless leaf?
463 468 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
464 469 end
465 470
466 471 if attrs['parent_issue_id'].present?
467 472 s = attrs['parent_issue_id'].to_s
468 473 unless (m = s.match(%r{\A#?(\d+)\z})) && (m[1] == parent_id.to_s || Issue.visible(user).exists?(m[1]))
469 474 @invalid_parent_issue_id = attrs.delete('parent_issue_id')
470 475 end
471 476 end
472 477
473 478 if attrs['custom_field_values'].present?
474 479 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
475 480 attrs['custom_field_values'].select! {|k, v| editable_custom_field_ids.include?(k.to_s)}
476 481 end
477 482
478 483 if attrs['custom_fields'].present?
479 484 editable_custom_field_ids = editable_custom_field_values(user).map {|v| v.custom_field_id.to_s}
480 485 attrs['custom_fields'].select! {|c| editable_custom_field_ids.include?(c['id'].to_s)}
481 486 end
482 487
483 488 # mass-assignment security bypass
484 489 assign_attributes attrs, :without_protection => true
485 490 end
486 491
487 492 def disabled_core_fields
488 493 tracker ? tracker.disabled_core_fields : []
489 494 end
490 495
491 496 # Returns the custom_field_values that can be edited by the given user
492 497 def editable_custom_field_values(user=nil)
493 498 visible_custom_field_values(user).reject do |value|
494 499 read_only_attribute_names(user).include?(value.custom_field_id.to_s)
495 500 end
496 501 end
497 502
498 503 # Returns the custom fields that can be edited by the given user
499 504 def editable_custom_fields(user=nil)
500 505 editable_custom_field_values(user).map(&:custom_field).uniq
501 506 end
502 507
503 508 # Returns the names of attributes that are read-only for user or the current user
504 509 # For users with multiple roles, the read-only fields are the intersection of
505 510 # read-only fields of each role
506 511 # The result is an array of strings where sustom fields are represented with their ids
507 512 #
508 513 # Examples:
509 514 # issue.read_only_attribute_names # => ['due_date', '2']
510 515 # issue.read_only_attribute_names(user) # => []
511 516 def read_only_attribute_names(user=nil)
512 517 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'readonly'}.keys
513 518 end
514 519
515 520 # Returns the names of required attributes for user or the current user
516 521 # For users with multiple roles, the required fields are the intersection of
517 522 # required fields of each role
518 523 # The result is an array of strings where sustom fields are represented with their ids
519 524 #
520 525 # Examples:
521 526 # issue.required_attribute_names # => ['due_date', '2']
522 527 # issue.required_attribute_names(user) # => []
523 528 def required_attribute_names(user=nil)
524 529 workflow_rule_by_attribute(user).reject {|attr, rule| rule != 'required'}.keys
525 530 end
526 531
527 532 # Returns true if the attribute is required for user
528 533 def required_attribute?(name, user=nil)
529 534 required_attribute_names(user).include?(name.to_s)
530 535 end
531 536
532 537 # Returns a hash of the workflow rule by attribute for the given user
533 538 #
534 539 # Examples:
535 540 # issue.workflow_rule_by_attribute # => {'due_date' => 'required', 'start_date' => 'readonly'}
536 541 def workflow_rule_by_attribute(user=nil)
537 542 return @workflow_rule_by_attribute if @workflow_rule_by_attribute && user.nil?
538 543
539 544 user_real = user || User.current
540 545 roles = user_real.admin ? Role.all.to_a : user_real.roles_for_project(project)
541 546 roles = roles.select(&:consider_workflow?)
542 547 return {} if roles.empty?
543 548
544 549 result = {}
545 550 workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
546 551 if workflow_permissions.any?
547 552 workflow_rules = workflow_permissions.inject({}) do |h, wp|
548 553 h[wp.field_name] ||= []
549 554 h[wp.field_name] << wp.rule
550 555 h
551 556 end
552 557 workflow_rules.each do |attr, rules|
553 558 next if rules.size < roles.size
554 559 uniq_rules = rules.uniq
555 560 if uniq_rules.size == 1
556 561 result[attr] = uniq_rules.first
557 562 else
558 563 result[attr] = 'required'
559 564 end
560 565 end
561 566 end
562 567 @workflow_rule_by_attribute = result if user.nil?
563 568 result
564 569 end
565 570 private :workflow_rule_by_attribute
566 571
567 572 def done_ratio
568 573 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
569 574 status.default_done_ratio
570 575 else
571 576 read_attribute(:done_ratio)
572 577 end
573 578 end
574 579
575 580 def self.use_status_for_done_ratio?
576 581 Setting.issue_done_ratio == 'issue_status'
577 582 end
578 583
579 584 def self.use_field_for_done_ratio?
580 585 Setting.issue_done_ratio == 'issue_field'
581 586 end
582 587
583 588 def validate_issue
584 589 if due_date && start_date && (start_date_changed? || due_date_changed?) && due_date < start_date
585 590 errors.add :due_date, :greater_than_start_date
586 591 end
587 592
588 593 if start_date && start_date_changed? && soonest_start && start_date < soonest_start
589 594 errors.add :start_date, :earlier_than_minimum_start_date, :date => format_date(soonest_start)
590 595 end
591 596
592 597 if fixed_version
593 598 if !assignable_versions.include?(fixed_version)
594 599 errors.add :fixed_version_id, :inclusion
595 600 elsif reopening? && fixed_version.closed?
596 601 errors.add :base, I18n.t(:error_can_not_reopen_issue_on_closed_version)
597 602 end
598 603 end
599 604
600 605 # Checks that the issue can not be added/moved to a disabled tracker
601 606 if project && (tracker_id_changed? || project_id_changed?)
602 607 unless project.trackers.include?(tracker)
603 608 errors.add :tracker_id, :inclusion
604 609 end
605 610 end
606 611
607 612 # Checks parent issue assignment
608 613 if @invalid_parent_issue_id.present?
609 614 errors.add :parent_issue_id, :invalid
610 615 elsif @parent_issue
611 616 if !valid_parent_project?(@parent_issue)
612 617 errors.add :parent_issue_id, :invalid
613 618 elsif (@parent_issue != parent) && (all_dependent_issues.include?(@parent_issue) || @parent_issue.all_dependent_issues.include?(self))
614 619 errors.add :parent_issue_id, :invalid
615 620 elsif !new_record?
616 621 # moving an existing issue
617 622 if move_possible?(@parent_issue)
618 623 # move accepted
619 624 else
620 625 errors.add :parent_issue_id, :invalid
621 626 end
622 627 end
623 628 end
624 629 end
625 630
626 631 # Validates the issue against additional workflow requirements
627 632 def validate_required_fields
628 633 user = new_record? ? author : current_journal.try(:user)
629 634
630 635 required_attribute_names(user).each do |attribute|
631 636 if attribute =~ /^\d+$/
632 637 attribute = attribute.to_i
633 638 v = custom_field_values.detect {|v| v.custom_field_id == attribute }
634 639 if v && v.value.blank?
635 640 errors.add :base, v.custom_field.name + ' ' + l('activerecord.errors.messages.blank')
636 641 end
637 642 else
638 643 if respond_to?(attribute) && send(attribute).blank? && !disabled_core_fields.include?(attribute)
639 644 errors.add attribute, :blank
640 645 end
641 646 end
642 647 end
643 648 end
644 649
645 650 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
646 651 # even if the user turns off the setting later
647 652 def update_done_ratio_from_issue_status
648 653 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
649 654 self.done_ratio = status.default_done_ratio
650 655 end
651 656 end
652 657
653 658 def init_journal(user, notes = "")
654 659 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
655 660 end
656 661
657 662 # Returns the current journal or nil if it's not initialized
658 663 def current_journal
659 664 @current_journal
660 665 end
661 666
662 667 # Returns the names of attributes that are journalized when updating the issue
663 668 def journalized_attribute_names
664 669 Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on closed_on)
665 670 end
666 671
667 672 # Returns the id of the last journal or nil
668 673 def last_journal_id
669 674 if new_record?
670 675 nil
671 676 else
672 677 journals.maximum(:id)
673 678 end
674 679 end
675 680
676 681 # Returns a scope for journals that have an id greater than journal_id
677 682 def journals_after(journal_id)
678 683 scope = journals.reorder("#{Journal.table_name}.id ASC")
679 684 if journal_id.present?
680 685 scope = scope.where("#{Journal.table_name}.id > ?", journal_id.to_i)
681 686 end
682 687 scope
683 688 end
684 689
685 690 # Returns the initial status of the issue
686 691 # Returns nil for a new issue
687 692 def status_was
688 693 if status_id_changed?
689 694 if status_id_was.to_i > 0
690 695 @status_was ||= IssueStatus.find_by_id(status_id_was)
691 696 end
692 697 else
693 698 @status_was ||= status
694 699 end
695 700 end
696 701
697 702 # Return true if the issue is closed, otherwise false
698 703 def closed?
699 704 status.present? && status.is_closed?
700 705 end
701 706
702 707 # Returns true if the issue was closed when loaded
703 708 def was_closed?
704 709 status_was.present? && status_was.is_closed?
705 710 end
706 711
707 712 # Return true if the issue is being reopened
708 713 def reopening?
709 714 if new_record?
710 715 false
711 716 else
712 717 status_id_changed? && !closed? && was_closed?
713 718 end
714 719 end
715 720 alias :reopened? :reopening?
716 721
717 722 # Return true if the issue is being closed
718 723 def closing?
719 724 if new_record?
720 725 closed?
721 726 else
722 727 status_id_changed? && closed? && !was_closed?
723 728 end
724 729 end
725 730
726 731 # Returns true if the issue is overdue
727 732 def overdue?
728 733 due_date.present? && (due_date < Date.today) && !closed?
729 734 end
730 735
731 736 # Is the amount of work done less than it should for the due date
732 737 def behind_schedule?
733 738 return false if start_date.nil? || due_date.nil?
734 739 done_date = start_date + ((due_date - start_date + 1) * done_ratio / 100).floor
735 740 return done_date <= Date.today
736 741 end
737 742
738 743 # Does this issue have children?
739 744 def children?
740 745 !leaf?
741 746 end
742 747
743 748 # Users the issue can be assigned to
744 749 def assignable_users
745 750 users = project.assignable_users.to_a
746 751 users << author if author
747 752 users << assigned_to if assigned_to
748 753 users.uniq.sort
749 754 end
750 755
751 756 # Versions that the issue can be assigned to
752 757 def assignable_versions
753 758 return @assignable_versions if @assignable_versions
754 759
755 760 versions = project.shared_versions.open.to_a
756 761 if fixed_version
757 762 if fixed_version_id_changed?
758 763 # nothing to do
759 764 elsif project_id_changed?
760 765 if project.shared_versions.include?(fixed_version)
761 766 versions << fixed_version
762 767 end
763 768 else
764 769 versions << fixed_version
765 770 end
766 771 end
767 772 @assignable_versions = versions.uniq.sort
768 773 end
769 774
770 775 # Returns true if this issue is blocked by another issue that is still open
771 776 def blocked?
772 777 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
773 778 end
774 779
775 780 # Returns the default status of the issue based on its tracker
776 781 # Returns nil if tracker is nil
777 782 def default_status
778 783 tracker.try(:default_status)
779 784 end
780 785
781 786 # Returns an array of statuses that user is able to apply
782 787 def new_statuses_allowed_to(user=User.current, include_default=false)
783 788 if new_record? && @copied_from
784 789 [default_status, @copied_from.status].compact.uniq.sort
785 790 else
786 791 initial_status = nil
787 792 if new_record?
788 793 initial_status = default_status
789 794 elsif tracker_id_changed?
790 795 if Tracker.where(:id => tracker_id_was, :default_status_id => status_id_was).any?
791 796 initial_status = default_status
792 797 elsif tracker.issue_status_ids.include?(status_id_was)
793 798 initial_status = IssueStatus.find_by_id(status_id_was)
794 799 else
795 800 initial_status = default_status
796 801 end
797 802 else
798 803 initial_status = status_was
799 804 end
800 805
801 806 initial_assigned_to_id = assigned_to_id_changed? ? assigned_to_id_was : assigned_to_id
802 807 assignee_transitions_allowed = initial_assigned_to_id.present? &&
803 808 (user.id == initial_assigned_to_id || user.group_ids.include?(initial_assigned_to_id))
804 809
805 810 statuses = []
806 811 if initial_status
807 812 statuses += initial_status.find_new_statuses_allowed_to(
808 813 user.admin ? Role.all.to_a : user.roles_for_project(project),
809 814 tracker,
810 815 author == user,
811 816 assignee_transitions_allowed
812 817 )
813 818 end
814 819 statuses << initial_status unless statuses.empty?
815 820 statuses << default_status if include_default
816 821 statuses = statuses.compact.uniq.sort
817 822 if blocked?
818 823 statuses.reject!(&:is_closed?)
819 824 end
820 825 statuses
821 826 end
822 827 end
823 828
824 829 # Returns the previous assignee if changed
825 830 def assigned_to_was
826 831 # assigned_to_id_was is reset before after_save callbacks
827 832 user_id = @previous_assigned_to_id || assigned_to_id_was
828 833 if user_id && user_id != assigned_to_id
829 834 @assigned_to_was ||= User.find_by_id(user_id)
830 835 end
831 836 end
832 837
833 838 # Returns the users that should be notified
834 839 def notified_users
835 840 notified = []
836 841 # Author and assignee are always notified unless they have been
837 842 # locked or don't want to be notified
838 843 notified << author if author
839 844 if assigned_to
840 845 notified += (assigned_to.is_a?(Group) ? assigned_to.users : [assigned_to])
841 846 end
842 847 if assigned_to_was
843 848 notified += (assigned_to_was.is_a?(Group) ? assigned_to_was.users : [assigned_to_was])
844 849 end
845 850 notified = notified.select {|u| u.active? && u.notify_about?(self)}
846 851
847 852 notified += project.notified_users
848 853 notified.uniq!
849 854 # Remove users that can not view the issue
850 855 notified.reject! {|user| !visible?(user)}
851 856 notified
852 857 end
853 858
854 859 # Returns the email addresses that should be notified
855 860 def recipients
856 861 notified_users.collect(&:mail)
857 862 end
858 863
859 864 def each_notification(users, &block)
860 865 if users.any?
861 866 if custom_field_values.detect {|value| !value.custom_field.visible?}
862 867 users_by_custom_field_visibility = users.group_by do |user|
863 868 visible_custom_field_values(user).map(&:custom_field_id).sort
864 869 end
865 870 users_by_custom_field_visibility.values.each do |users|
866 871 yield(users)
867 872 end
868 873 else
869 874 yield(users)
870 875 end
871 876 end
872 877 end
873 878
874 879 # Returns the number of hours spent on this issue
875 880 def spent_hours
876 881 @spent_hours ||= time_entries.sum(:hours) || 0
877 882 end
878 883
879 884 # Returns the total number of hours spent on this issue and its descendants
880 885 #
881 886 # Example:
882 887 # spent_hours => 0.0
883 888 # spent_hours => 50.2
884 889 def total_spent_hours
885 890 @total_spent_hours ||=
886 891 self_and_descendants.
887 892 joins("LEFT JOIN #{TimeEntry.table_name} ON #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id").
888 893 sum("#{TimeEntry.table_name}.hours").to_f || 0.0
889 894 end
890 895
891 896 def relations
892 897 @relations ||= IssueRelation::Relations.new(self, (relations_from + relations_to).sort)
893 898 end
894 899
895 900 # Preloads relations for a collection of issues
896 901 def self.load_relations(issues)
897 902 if issues.any?
898 903 relations = IssueRelation.where("issue_from_id IN (:ids) OR issue_to_id IN (:ids)", :ids => issues.map(&:id)).all
899 904 issues.each do |issue|
900 905 issue.instance_variable_set "@relations", relations.select {|r| r.issue_from_id == issue.id || r.issue_to_id == issue.id}
901 906 end
902 907 end
903 908 end
904 909
905 910 # Preloads visible spent time for a collection of issues
906 911 def self.load_visible_spent_hours(issues, user=User.current)
907 912 if issues.any?
908 913 hours_by_issue_id = TimeEntry.visible(user).group(:issue_id).sum(:hours)
909 914 issues.each do |issue|
910 915 issue.instance_variable_set "@spent_hours", (hours_by_issue_id[issue.id] || 0)
911 916 end
912 917 end
913 918 end
914 919
915 920 # Preloads visible relations for a collection of issues
916 921 def self.load_visible_relations(issues, user=User.current)
917 922 if issues.any?
918 923 issue_ids = issues.map(&:id)
919 924 # Relations with issue_from in given issues and visible issue_to
920 925 relations_from = IssueRelation.joins(:issue_to => :project).
921 926 where(visible_condition(user)).where(:issue_from_id => issue_ids).to_a
922 927 # Relations with issue_to in given issues and visible issue_from
923 928 relations_to = IssueRelation.joins(:issue_from => :project).
924 929 where(visible_condition(user)).
925 930 where(:issue_to_id => issue_ids).to_a
926 931 issues.each do |issue|
927 932 relations =
928 933 relations_from.select {|relation| relation.issue_from_id == issue.id} +
929 934 relations_to.select {|relation| relation.issue_to_id == issue.id}
930 935
931 936 issue.instance_variable_set "@relations", IssueRelation::Relations.new(issue, relations.sort)
932 937 end
933 938 end
934 939 end
935 940
936 941 # Finds an issue relation given its id.
937 942 def find_relation(relation_id)
938 943 IssueRelation.where("issue_to_id = ? OR issue_from_id = ?", id, id).find(relation_id)
939 944 end
940 945
941 946 # Returns all the other issues that depend on the issue
942 947 # The algorithm is a modified breadth first search (bfs)
943 948 def all_dependent_issues(except=[])
944 949 # The found dependencies
945 950 dependencies = []
946 951
947 952 # The visited flag for every node (issue) used by the breadth first search
948 953 eNOT_DISCOVERED = 0 # The issue is "new" to the algorithm, it has not seen it before.
949 954
950 955 ePROCESS_ALL = 1 # The issue is added to the queue. Process both children and relations of
951 956 # the issue when it is processed.
952 957
953 958 ePROCESS_RELATIONS_ONLY = 2 # The issue was added to the queue and will be output as dependent issue,
954 959 # but its children will not be added to the queue when it is processed.
955 960
956 961 eRELATIONS_PROCESSED = 3 # The related issues, the parent issue and the issue itself have been added to
957 962 # the queue, but its children have not been added.
958 963
959 964 ePROCESS_CHILDREN_ONLY = 4 # The relations and the parent of the issue have been added to the queue, but
960 965 # the children still need to be processed.
961 966
962 967 eALL_PROCESSED = 5 # The issue and all its children, its parent and its related issues have been
963 968 # added as dependent issues. It needs no further processing.
964 969
965 970 issue_status = Hash.new(eNOT_DISCOVERED)
966 971
967 972 # The queue
968 973 queue = []
969 974
970 975 # Initialize the bfs, add start node (self) to the queue
971 976 queue << self
972 977 issue_status[self] = ePROCESS_ALL
973 978
974 979 while (!queue.empty?) do
975 980 current_issue = queue.shift
976 981 current_issue_status = issue_status[current_issue]
977 982 dependencies << current_issue
978 983
979 984 # Add parent to queue, if not already in it.
980 985 parent = current_issue.parent
981 986 parent_status = issue_status[parent]
982 987
983 988 if parent && (parent_status == eNOT_DISCOVERED) && !except.include?(parent)
984 989 queue << parent
985 990 issue_status[parent] = ePROCESS_RELATIONS_ONLY
986 991 end
987 992
988 993 # Add children to queue, but only if they are not already in it and
989 994 # the children of the current node need to be processed.
990 995 if (current_issue_status == ePROCESS_CHILDREN_ONLY || current_issue_status == ePROCESS_ALL)
991 996 current_issue.children.each do |child|
992 997 next if except.include?(child)
993 998
994 999 if (issue_status[child] == eNOT_DISCOVERED)
995 1000 queue << child
996 1001 issue_status[child] = ePROCESS_ALL
997 1002 elsif (issue_status[child] == eRELATIONS_PROCESSED)
998 1003 queue << child
999 1004 issue_status[child] = ePROCESS_CHILDREN_ONLY
1000 1005 elsif (issue_status[child] == ePROCESS_RELATIONS_ONLY)
1001 1006 queue << child
1002 1007 issue_status[child] = ePROCESS_ALL
1003 1008 end
1004 1009 end
1005 1010 end
1006 1011
1007 1012 # Add related issues to the queue, if they are not already in it.
1008 1013 current_issue.relations_from.map(&:issue_to).each do |related_issue|
1009 1014 next if except.include?(related_issue)
1010 1015
1011 1016 if (issue_status[related_issue] == eNOT_DISCOVERED)
1012 1017 queue << related_issue
1013 1018 issue_status[related_issue] = ePROCESS_ALL
1014 1019 elsif (issue_status[related_issue] == eRELATIONS_PROCESSED)
1015 1020 queue << related_issue
1016 1021 issue_status[related_issue] = ePROCESS_CHILDREN_ONLY
1017 1022 elsif (issue_status[related_issue] == ePROCESS_RELATIONS_ONLY)
1018 1023 queue << related_issue
1019 1024 issue_status[related_issue] = ePROCESS_ALL
1020 1025 end
1021 1026 end
1022 1027
1023 1028 # Set new status for current issue
1024 1029 if (current_issue_status == ePROCESS_ALL) || (current_issue_status == ePROCESS_CHILDREN_ONLY)
1025 1030 issue_status[current_issue] = eALL_PROCESSED
1026 1031 elsif (current_issue_status == ePROCESS_RELATIONS_ONLY)
1027 1032 issue_status[current_issue] = eRELATIONS_PROCESSED
1028 1033 end
1029 1034 end # while
1030 1035
1031 1036 # Remove the issues from the "except" parameter from the result array
1032 1037 dependencies -= except
1033 1038 dependencies.delete(self)
1034 1039
1035 1040 dependencies
1036 1041 end
1037 1042
1038 1043 # Returns an array of issues that duplicate this one
1039 1044 def duplicates
1040 1045 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
1041 1046 end
1042 1047
1043 1048 # Returns the due date or the target due date if any
1044 1049 # Used on gantt chart
1045 1050 def due_before
1046 1051 due_date || (fixed_version ? fixed_version.effective_date : nil)
1047 1052 end
1048 1053
1049 1054 # Returns the time scheduled for this issue.
1050 1055 #
1051 1056 # Example:
1052 1057 # Start Date: 2/26/09, End Date: 3/04/09
1053 1058 # duration => 6
1054 1059 def duration
1055 1060 (start_date && due_date) ? due_date - start_date : 0
1056 1061 end
1057 1062
1058 1063 # Returns the duration in working days
1059 1064 def working_duration
1060 1065 (start_date && due_date) ? working_days(start_date, due_date) : 0
1061 1066 end
1062 1067
1063 1068 def soonest_start(reload=false)
1064 1069 @soonest_start = nil if reload
1065 1070 @soonest_start ||= (
1066 1071 relations_to(reload).collect{|relation| relation.successor_soonest_start} +
1067 1072 [(@parent_issue || parent).try(:soonest_start)]
1068 1073 ).compact.max
1069 1074 end
1070 1075
1071 1076 # Sets start_date on the given date or the next working day
1072 1077 # and changes due_date to keep the same working duration.
1073 1078 def reschedule_on(date)
1074 1079 wd = working_duration
1075 1080 date = next_working_date(date)
1076 1081 self.start_date = date
1077 1082 self.due_date = add_working_days(date, wd)
1078 1083 end
1079 1084
1080 1085 # Reschedules the issue on the given date or the next working day and saves the record.
1081 1086 # If the issue is a parent task, this is done by rescheduling its subtasks.
1082 1087 def reschedule_on!(date)
1083 1088 return if date.nil?
1084 1089 if leaf?
1085 1090 if start_date.nil? || start_date != date
1086 1091 if start_date && start_date > date
1087 1092 # Issue can not be moved earlier than its soonest start date
1088 1093 date = [soonest_start(true), date].compact.max
1089 1094 end
1090 1095 reschedule_on(date)
1091 1096 begin
1092 1097 save
1093 1098 rescue ActiveRecord::StaleObjectError
1094 1099 reload
1095 1100 reschedule_on(date)
1096 1101 save
1097 1102 end
1098 1103 end
1099 1104 else
1100 1105 leaves.each do |leaf|
1101 1106 if leaf.start_date
1102 1107 # Only move subtask if it starts at the same date as the parent
1103 1108 # or if it starts before the given date
1104 1109 if start_date == leaf.start_date || date > leaf.start_date
1105 1110 leaf.reschedule_on!(date)
1106 1111 end
1107 1112 else
1108 1113 leaf.reschedule_on!(date)
1109 1114 end
1110 1115 end
1111 1116 end
1112 1117 end
1113 1118
1114 1119 def <=>(issue)
1115 1120 if issue.nil?
1116 1121 -1
1117 1122 elsif root_id != issue.root_id
1118 1123 (root_id || 0) <=> (issue.root_id || 0)
1119 1124 else
1120 1125 (lft || 0) <=> (issue.lft || 0)
1121 1126 end
1122 1127 end
1123 1128
1124 1129 def to_s
1125 1130 "#{tracker} ##{id}: #{subject}"
1126 1131 end
1127 1132
1128 1133 # Returns a string of css classes that apply to the issue
1129 1134 def css_classes(user=User.current)
1130 1135 s = "issue tracker-#{tracker_id} status-#{status_id} #{priority.try(:css_classes)}"
1131 1136 s << ' closed' if closed?
1132 1137 s << ' overdue' if overdue?
1133 1138 s << ' child' if child?
1134 1139 s << ' parent' unless leaf?
1135 1140 s << ' private' if is_private?
1136 1141 if user.logged?
1137 1142 s << ' created-by-me' if author_id == user.id
1138 1143 s << ' assigned-to-me' if assigned_to_id == user.id
1139 1144 s << ' assigned-to-my-group' if user.groups.any? {|g| g.id == assigned_to_id}
1140 1145 end
1141 1146 s
1142 1147 end
1143 1148
1144 1149 # Unassigns issues from +version+ if it's no longer shared with issue's project
1145 1150 def self.update_versions_from_sharing_change(version)
1146 1151 # Update issues assigned to the version
1147 1152 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
1148 1153 end
1149 1154
1150 1155 # Unassigns issues from versions that are no longer shared
1151 1156 # after +project+ was moved
1152 1157 def self.update_versions_from_hierarchy_change(project)
1153 1158 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
1154 1159 # Update issues of the moved projects and issues assigned to a version of a moved project
1155 1160 Issue.update_versions(
1156 1161 ["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)",
1157 1162 moved_project_ids, moved_project_ids]
1158 1163 )
1159 1164 end
1160 1165
1161 1166 def parent_issue_id=(arg)
1162 1167 s = arg.to_s.strip.presence
1163 1168 if s && (m = s.match(%r{\A#?(\d+)\z})) && (@parent_issue = Issue.find_by_id(m[1]))
1164 1169 @invalid_parent_issue_id = nil
1165 1170 elsif s.blank?
1166 1171 @parent_issue = nil
1167 1172 @invalid_parent_issue_id = nil
1168 1173 else
1169 1174 @parent_issue = nil
1170 1175 @invalid_parent_issue_id = arg
1171 1176 end
1172 1177 end
1173 1178
1174 1179 def parent_issue_id
1175 1180 if @invalid_parent_issue_id
1176 1181 @invalid_parent_issue_id
1177 1182 elsif instance_variable_defined? :@parent_issue
1178 1183 @parent_issue.nil? ? nil : @parent_issue.id
1179 1184 else
1180 1185 parent_id
1181 1186 end
1182 1187 end
1183 1188
1184 1189 def set_parent_id
1185 1190 self.parent_id = parent_issue_id
1186 1191 end
1187 1192
1188 1193 # Returns true if issue's project is a valid
1189 1194 # parent issue project
1190 1195 def valid_parent_project?(issue=parent)
1191 1196 return true if issue.nil? || issue.project_id == project_id
1192 1197
1193 1198 case Setting.cross_project_subtasks
1194 1199 when 'system'
1195 1200 true
1196 1201 when 'tree'
1197 1202 issue.project.root == project.root
1198 1203 when 'hierarchy'
1199 1204 issue.project.is_or_is_ancestor_of?(project) || issue.project.is_descendant_of?(project)
1200 1205 when 'descendants'
1201 1206 issue.project.is_or_is_ancestor_of?(project)
1202 1207 else
1203 1208 false
1204 1209 end
1205 1210 end
1206 1211
1207 1212 # Returns an issue scope based on project and scope
1208 1213 def self.cross_project_scope(project, scope=nil)
1209 1214 if project.nil?
1210 1215 return Issue
1211 1216 end
1212 1217 case scope
1213 1218 when 'all', 'system'
1214 1219 Issue
1215 1220 when 'tree'
1216 1221 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1217 1222 :lft => project.root.lft, :rgt => project.root.rgt)
1218 1223 when 'hierarchy'
1219 1224 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt) OR (#{Project.table_name}.lft < :lft AND #{Project.table_name}.rgt > :rgt)",
1220 1225 :lft => project.lft, :rgt => project.rgt)
1221 1226 when 'descendants'
1222 1227 Issue.joins(:project).where("(#{Project.table_name}.lft >= :lft AND #{Project.table_name}.rgt <= :rgt)",
1223 1228 :lft => project.lft, :rgt => project.rgt)
1224 1229 else
1225 1230 Issue.where(:project_id => project.id)
1226 1231 end
1227 1232 end
1228 1233
1229 1234 def self.by_tracker(project)
1230 1235 count_and_group_by(:project => project, :association => :tracker)
1231 1236 end
1232 1237
1233 1238 def self.by_version(project)
1234 1239 count_and_group_by(:project => project, :association => :fixed_version)
1235 1240 end
1236 1241
1237 1242 def self.by_priority(project)
1238 1243 count_and_group_by(:project => project, :association => :priority)
1239 1244 end
1240 1245
1241 1246 def self.by_category(project)
1242 1247 count_and_group_by(:project => project, :association => :category)
1243 1248 end
1244 1249
1245 1250 def self.by_assigned_to(project)
1246 1251 count_and_group_by(:project => project, :association => :assigned_to)
1247 1252 end
1248 1253
1249 1254 def self.by_author(project)
1250 1255 count_and_group_by(:project => project, :association => :author)
1251 1256 end
1252 1257
1253 1258 def self.by_subproject(project)
1254 1259 r = count_and_group_by(:project => project, :with_subprojects => true, :association => :project)
1255 1260 r.reject {|r| r["project_id"] == project.id.to_s}
1256 1261 end
1257 1262
1258 1263 # Query generator for selecting groups of issue counts for a project
1259 1264 # based on specific criteria
1260 1265 #
1261 1266 # Options
1262 1267 # * project - Project to search in.
1263 1268 # * with_subprojects - Includes subprojects issues if set to true.
1264 1269 # * association - Symbol. Association for grouping.
1265 1270 def self.count_and_group_by(options)
1266 1271 assoc = reflect_on_association(options[:association])
1267 1272 select_field = assoc.foreign_key
1268 1273
1269 1274 Issue.
1270 1275 visible(User.current, :project => options[:project], :with_subprojects => options[:with_subprojects]).
1271 1276 joins(:status, assoc.name).
1272 1277 group(:status_id, :is_closed, select_field).
1273 1278 count.
1274 1279 map do |columns, total|
1275 1280 status_id, is_closed, field_value = columns
1276 1281 is_closed = ['t', 'true', '1'].include?(is_closed.to_s)
1277 1282 {
1278 1283 "status_id" => status_id.to_s,
1279 1284 "closed" => is_closed,
1280 1285 select_field => field_value.to_s,
1281 1286 "total" => total.to_s
1282 1287 }
1283 1288 end
1284 1289 end
1285 1290
1286 1291 # Returns a scope of projects that user can assign the issue to
1287 1292 def allowed_target_projects(user=User.current)
1288 1293 current_project = new_record? ? nil : project
1289 1294 self.class.allowed_target_projects(user, current_project)
1290 1295 end
1291 1296
1292 1297 # Returns a scope of projects that user can assign issues to
1293 1298 # If current_project is given, it will be included in the scope
1294 1299 def self.allowed_target_projects(user=User.current, current_project=nil)
1295 1300 condition = Project.allowed_to_condition(user, :add_issues)
1296 1301 if current_project
1297 1302 condition = ["(#{condition}) OR #{Project.table_name}.id = ?", current_project.id]
1298 1303 end
1299 1304 Project.where(condition)
1300 1305 end
1301 1306
1302 1307 private
1303 1308
1304 1309 def after_project_change
1305 1310 # Update project_id on related time entries
1306 1311 TimeEntry.where({:issue_id => id}).update_all(["project_id = ?", project_id])
1307 1312
1308 1313 # Delete issue relations
1309 1314 unless Setting.cross_project_issue_relations?
1310 1315 relations_from.clear
1311 1316 relations_to.clear
1312 1317 end
1313 1318
1314 1319 # Move subtasks that were in the same project
1315 1320 children.each do |child|
1316 1321 next unless child.project_id == project_id_was
1317 1322 # Change project and keep project
1318 1323 child.send :project=, project, true
1319 1324 unless child.save
1320 1325 raise ActiveRecord::Rollback
1321 1326 end
1322 1327 end
1323 1328 end
1324 1329
1325 1330 # Callback for after the creation of an issue by copy
1326 1331 # * adds a "copied to" relation with the copied issue
1327 1332 # * copies subtasks from the copied issue
1328 1333 def after_create_from_copy
1329 1334 return unless copy? && !@after_create_from_copy_handled
1330 1335
1331 1336 if (@copied_from.project_id == project_id || Setting.cross_project_issue_relations?) && @copy_options[:link] != false
1332 1337 if @current_journal
1333 1338 @copied_from.init_journal(@current_journal.user)
1334 1339 end
1335 1340 relation = IssueRelation.new(:issue_from => @copied_from, :issue_to => self, :relation_type => IssueRelation::TYPE_COPIED_TO)
1336 1341 unless relation.save
1337 1342 logger.error "Could not create relation while copying ##{@copied_from.id} to ##{id} due to validation errors: #{relation.errors.full_messages.join(', ')}" if logger
1338 1343 end
1339 1344 end
1340 1345
1341 1346 unless @copied_from.leaf? || @copy_options[:subtasks] == false
1342 1347 copy_options = (@copy_options || {}).merge(:subtasks => false)
1343 1348 copied_issue_ids = {@copied_from.id => self.id}
1344 1349 @copied_from.reload.descendants.reorder("#{Issue.table_name}.lft").each do |child|
1345 1350 # Do not copy self when copying an issue as a descendant of the copied issue
1346 1351 next if child == self
1347 1352 # Do not copy subtasks of issues that were not copied
1348 1353 next unless copied_issue_ids[child.parent_id]
1349 1354 # Do not copy subtasks that are not visible to avoid potential disclosure of private data
1350 1355 unless child.visible?
1351 1356 logger.error "Subtask ##{child.id} was not copied during ##{@copied_from.id} copy because it is not visible to the current user" if logger
1352 1357 next
1353 1358 end
1354 1359 copy = Issue.new.copy_from(child, copy_options)
1355 1360 if @current_journal
1356 1361 copy.init_journal(@current_journal.user)
1357 1362 end
1358 1363 copy.author = author
1359 1364 copy.project = project
1360 1365 copy.parent_issue_id = copied_issue_ids[child.parent_id]
1361 1366 unless copy.save
1362 1367 logger.error "Could not copy subtask ##{child.id} while copying ##{@copied_from.id} to ##{id} due to validation errors: #{copy.errors.full_messages.join(', ')}" if logger
1363 1368 next
1364 1369 end
1365 1370 copied_issue_ids[child.id] = copy.id
1366 1371 end
1367 1372 end
1368 1373 @after_create_from_copy_handled = true
1369 1374 end
1370 1375
1371 1376 def update_nested_set_attributes
1372 1377 if parent_id_changed?
1373 1378 update_nested_set_attributes_on_parent_change
1374 1379 end
1375 1380 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
1376 1381 end
1377 1382
1378 1383 # Updates the nested set for when an existing issue is moved
1379 1384 def update_nested_set_attributes_on_parent_change
1380 1385 former_parent_id = parent_id_was
1381 1386 # delete invalid relations of all descendants
1382 1387 self_and_descendants.each do |issue|
1383 1388 issue.relations.each do |relation|
1384 1389 relation.destroy unless relation.valid?
1385 1390 end
1386 1391 end
1387 1392 # update former parent
1388 1393 recalculate_attributes_for(former_parent_id) if former_parent_id
1389 1394 end
1390 1395
1391 1396 def update_parent_attributes
1392 1397 if parent_id
1393 1398 recalculate_attributes_for(parent_id)
1394 1399 association(:parent).reset
1395 1400 end
1396 1401 end
1397 1402
1398 1403 def recalculate_attributes_for(issue_id)
1399 1404 if issue_id && p = Issue.find_by_id(issue_id)
1400 1405 # priority = highest priority of children
1401 1406 if priority_position = p.children.joins(:priority).maximum("#{IssuePriority.table_name}.position")
1402 1407 p.priority = IssuePriority.find_by_position(priority_position)
1403 1408 end
1404 1409
1405 1410 # start/due dates = lowest/highest dates of children
1406 1411 p.start_date = p.children.minimum(:start_date)
1407 1412 p.due_date = p.children.maximum(:due_date)
1408 1413 if p.start_date && p.due_date && p.due_date < p.start_date
1409 1414 p.start_date, p.due_date = p.due_date, p.start_date
1410 1415 end
1411 1416
1412 1417 # done ratio = weighted average ratio of leaves
1413 1418 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
1414 1419 leaves_count = p.leaves.count
1415 1420 if leaves_count > 0
1416 1421 average = p.leaves.where("estimated_hours > 0").average(:estimated_hours).to_f
1417 1422 if average == 0
1418 1423 average = 1
1419 1424 end
1420 1425 done = p.leaves.joins(:status).
1421 1426 sum("COALESCE(CASE WHEN estimated_hours > 0 THEN estimated_hours ELSE NULL END, #{average}) " +
1422 1427 "* (CASE WHEN is_closed = #{self.class.connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)").to_f
1423 1428 progress = done / (average * leaves_count)
1424 1429 p.done_ratio = progress.round
1425 1430 end
1426 1431 end
1427 1432
1428 1433 # estimate = sum of leaves estimates
1429 1434 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
1430 1435 p.estimated_hours = nil if p.estimated_hours == 0.0
1431 1436
1432 1437 # ancestors will be recursively updated
1433 1438 p.save(:validate => false)
1434 1439 end
1435 1440 end
1436 1441
1437 1442 # Update issues so their versions are not pointing to a
1438 1443 # fixed_version that is not shared with the issue's project
1439 1444 def self.update_versions(conditions=nil)
1440 1445 # Only need to update issues with a fixed_version from
1441 1446 # a different project and that is not systemwide shared
1442 1447 Issue.joins(:project, :fixed_version).
1443 1448 where("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
1444 1449 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
1445 1450 " AND #{Version.table_name}.sharing <> 'system'").
1446 1451 where(conditions).each do |issue|
1447 1452 next if issue.project.nil? || issue.fixed_version.nil?
1448 1453 unless issue.project.shared_versions.include?(issue.fixed_version)
1449 1454 issue.init_journal(User.current)
1450 1455 issue.fixed_version = nil
1451 1456 issue.save
1452 1457 end
1453 1458 end
1454 1459 end
1455 1460
1456 1461 # Callback on file attachment
1457 1462 def attachment_added(attachment)
1458 1463 if current_journal && !attachment.new_record?
1459 1464 current_journal.journalize_attachment(attachment, :added)
1460 1465 end
1461 1466 end
1462 1467
1463 1468 # Callback on attachment deletion
1464 1469 def attachment_removed(attachment)
1465 1470 if current_journal && !attachment.new_record?
1466 1471 current_journal.journalize_attachment(attachment, :removed)
1467 1472 current_journal.save
1468 1473 end
1469 1474 end
1470 1475
1471 1476 # Called after a relation is added
1472 1477 def relation_added(relation)
1473 1478 if current_journal
1474 1479 current_journal.journalize_relation(relation, :added)
1475 1480 current_journal.save
1476 1481 end
1477 1482 end
1478 1483
1479 1484 # Called after a relation is removed
1480 1485 def relation_removed(relation)
1481 1486 if current_journal
1482 1487 current_journal.journalize_relation(relation, :removed)
1483 1488 current_journal.save
1484 1489 end
1485 1490 end
1486 1491
1487 1492 # Default assignment based on category
1488 1493 def default_assign
1489 1494 if assigned_to.nil? && category && category.assigned_to
1490 1495 self.assigned_to = category.assigned_to
1491 1496 end
1492 1497 end
1493 1498
1494 1499 # Updates start/due dates of following issues
1495 1500 def reschedule_following_issues
1496 1501 if start_date_changed? || due_date_changed?
1497 1502 relations_from.each do |relation|
1498 1503 relation.set_issue_to_dates
1499 1504 end
1500 1505 end
1501 1506 end
1502 1507
1503 1508 # Closes duplicates if the issue is being closed
1504 1509 def close_duplicates
1505 1510 if closing?
1506 1511 duplicates.each do |duplicate|
1507 1512 # Reload is needed in case the duplicate was updated by a previous duplicate
1508 1513 duplicate.reload
1509 1514 # Don't re-close it if it's already closed
1510 1515 next if duplicate.closed?
1511 1516 # Same user and notes
1512 1517 if @current_journal
1513 1518 duplicate.init_journal(@current_journal.user, @current_journal.notes)
1514 1519 end
1515 1520 duplicate.update_attribute :status, self.status
1516 1521 end
1517 1522 end
1518 1523 end
1519 1524
1520 1525 # Make sure updated_on is updated when adding a note and set updated_on now
1521 1526 # so we can set closed_on with the same value on closing
1522 1527 def force_updated_on_change
1523 1528 if @current_journal || changed?
1524 1529 self.updated_on = current_time_from_proper_timezone
1525 1530 if new_record?
1526 1531 self.created_on = updated_on
1527 1532 end
1528 1533 end
1529 1534 end
1530 1535
1531 1536 # Callback for setting closed_on when the issue is closed.
1532 1537 # The closed_on attribute stores the time of the last closing
1533 1538 # and is preserved when the issue is reopened.
1534 1539 def update_closed_on
1535 1540 if closing?
1536 1541 self.closed_on = updated_on
1537 1542 end
1538 1543 end
1539 1544
1540 1545 # Saves the changes in a Journal
1541 1546 # Called after_save
1542 1547 def create_journal
1543 1548 if current_journal
1544 1549 current_journal.save
1545 1550 end
1546 1551 end
1547 1552
1548 1553 def send_notification
1549 1554 if Setting.notified_events.include?('issue_added')
1550 1555 Mailer.deliver_issue_add(self)
1551 1556 end
1552 1557 end
1553 1558
1554 1559 # Stores the previous assignee so we can still have access
1555 1560 # to it during after_save callbacks (assigned_to_id_was is reset)
1556 1561 def set_assigned_to_was
1557 1562 @previous_assigned_to_id = assigned_to_id_was
1558 1563 end
1559 1564
1560 1565 # Clears the previous assignee at the end of after_save callbacks
1561 1566 def clear_assigned_to_was
1562 1567 @assigned_to_was = nil
1563 1568 @previous_assigned_to_id = nil
1564 1569 end
1565 1570 end
@@ -1,52 +1,52
1 1 <%= labelled_form_for @issue, :html => {:id => 'issue-form', :multipart => true} do |f| %>
2 2 <%= error_messages_for 'issue', 'time_entry' %>
3 3 <%= render :partial => 'conflict' if @conflict %>
4 4 <div class="box">
5 <% if @edit_allowed %>
5 <% if @issue.attributes_editable? %>
6 6 <fieldset class="tabular"><legend><%= l(:label_change_properties) %></legend>
7 7 <div id="all_attributes">
8 8 <%= render :partial => 'form', :locals => {:f => f} %>
9 9 </div>
10 10 </fieldset>
11 11 <% end %>
12 12 <% if User.current.allowed_to?(:log_time, @project) %>
13 13 <fieldset class="tabular"><legend><%= l(:button_log_time) %></legend>
14 14 <%= labelled_fields_for :time_entry, @time_entry do |time_entry| %>
15 15 <div class="splitcontentleft">
16 16 <p><%= time_entry.text_field :hours, :size => 6, :label => :label_spent_time %> <%= l(:field_hours) %></p>
17 17 </div>
18 18 <div class="splitcontentright">
19 19 <p><%= time_entry.select :activity_id, activity_collection_for_select_options %></p>
20 20 </div>
21 21 <p><%= time_entry.text_field :comments, :size => 60 %></p>
22 22 <% @time_entry.custom_field_values.each do |value| %>
23 23 <p><%= custom_field_tag_with_label :time_entry, value %></p>
24 24 <% end %>
25 25 <% end %>
26 26 </fieldset>
27 27 <% end %>
28 28
29 29 <fieldset><legend><%= l(:field_notes) %></legend>
30 30 <%= f.text_area :notes, :cols => 60, :rows => 10, :class => 'wiki-edit', :no_label => true %>
31 31 <%= wikitoolbar_for 'issue_notes' %>
32 32
33 33 <% if @issue.safe_attribute? 'private_notes' %>
34 34 <%= f.check_box :private_notes, :no_label => true %> <label for="issue_private_notes"><%= l(:field_private_notes) %></label>
35 35 <% end %>
36 36
37 37 <%= call_hook(:view_issues_edit_notes_bottom, { :issue => @issue, :notes => @notes, :form => f }) %>
38 38 </fieldset>
39 39
40 40 <fieldset><legend><%= l(:label_attachment_plural) %></legend>
41 41 <p><%= render :partial => 'attachments/form', :locals => {:container => @issue} %></p>
42 42 </fieldset>
43 43 </div>
44 44
45 45 <%= f.hidden_field :lock_version %>
46 46 <%= hidden_field_tag 'last_journal_id', params[:last_journal_id] || @issue.last_journal_id %>
47 47 <%= submit_tag l(:button_submit) %>
48 48 <%= preview_link preview_edit_issue_path(:project_id => @project, :id => @issue), 'issue-form' %>
49 49 | <%= link_to l(:button_cancel), {}, :onclick => "$('#update').hide(); return false;" %>
50 50 <% end %>
51 51
52 52 <div id="preview" class="wiki"></div>
General Comments 0
You need to be logged in to leave comments. Login now