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