##// END OF EJS Templates
Allow commits to reference issues of parent projects and subprojects (#4674)....
Jean-Philippe Lang -
r3243:d43c860448ce
parent child
Show More
@@ -1,572 +1,572
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, :reply]
23 23 before_filter :find_issues, :only => [:bulk_edit, :move, :destroy]
24 24 before_filter :find_project, :only => [:new, :update_form, :preview]
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 def index
55 55 retrieve_query
56 56 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
57 57 sort_update({'id' => "#{Issue.table_name}.id"}.merge(@query.available_columns.inject({}) {|h, c| h[c.name.to_s] = c.sortable; h}))
58 58
59 59 if @query.valid?
60 60 limit = per_page_option
61 61 respond_to do |format|
62 62 format.html { }
63 63 format.xml { }
64 64 format.atom { limit = Setting.feeds_limit.to_i }
65 65 format.csv { limit = Setting.issues_export_limit.to_i }
66 66 format.pdf { limit = Setting.issues_export_limit.to_i }
67 67 end
68 68
69 69 @issue_count = @query.issue_count
70 70 @issue_pages = Paginator.new self, @issue_count, limit, params['page']
71 71 @issues = @query.issues(:include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
72 72 :order => sort_clause,
73 73 :offset => @issue_pages.current.offset,
74 74 :limit => limit)
75 75 @issue_count_by_group = @query.issue_count_by_group
76 76
77 77 respond_to do |format|
78 78 format.html { render :template => 'issues/index.rhtml', :layout => !request.xhr? }
79 79 format.xml { render :layout => false }
80 80 format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
81 81 format.csv { send_data(issues_to_csv(@issues, @project), :type => 'text/csv; header=present', :filename => 'export.csv') }
82 82 format.pdf { send_data(issues_to_pdf(@issues, @project, @query), :type => 'application/pdf', :filename => 'export.pdf') }
83 83 end
84 84 else
85 85 # Send html if the query is not valid
86 86 render(:template => 'issues/index.rhtml', :layout => !request.xhr?)
87 87 end
88 88 rescue ActiveRecord::RecordNotFound
89 89 render_404
90 90 end
91 91
92 92 def changes
93 93 retrieve_query
94 94 sort_init 'id', 'desc'
95 95 sort_update({'id' => "#{Issue.table_name}.id"}.merge(@query.available_columns.inject({}) {|h, c| h[c.name.to_s] = c.sortable; h}))
96 96
97 97 if @query.valid?
98 98 @journals = @query.journals(:order => "#{Journal.table_name}.created_on DESC",
99 99 :limit => 25)
100 100 end
101 101 @title = (@project ? @project.name : Setting.app_title) + ": " + (@query.new_record? ? l(:label_changes_details) : @query.name)
102 102 render :layout => false, :content_type => 'application/atom+xml'
103 103 rescue ActiveRecord::RecordNotFound
104 104 render_404
105 105 end
106 106
107 107 def show
108 108 @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
109 109 @journals.each_with_index {|j,i| j.indice = i+1}
110 110 @journals.reverse! if User.current.wants_comments_in_reverse_order?
111 @changesets = @issue.changesets
111 @changesets = @issue.changesets.visible.all
112 112 @changesets.reverse! if User.current.wants_comments_in_reverse_order?
113 113 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
114 114 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
115 115 @priorities = IssuePriority.all
116 116 @time_entry = TimeEntry.new
117 117 respond_to do |format|
118 118 format.html { render :template => 'issues/show.rhtml' }
119 119 format.xml { render :layout => false }
120 120 format.atom { render :action => 'changes', :layout => false, :content_type => 'application/atom+xml' }
121 121 format.pdf { send_data(issue_to_pdf(@issue), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
122 122 end
123 123 end
124 124
125 125 # Add a new issue
126 126 # The new issue will be created from an existing one if copy_from parameter is given
127 127 def new
128 128 @issue = Issue.new
129 129 @issue.copy_from(params[:copy_from]) if params[:copy_from]
130 130 @issue.project = @project
131 131 # Tracker must be set before custom field values
132 132 @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
133 133 if @issue.tracker.nil?
134 134 render_error l(:error_no_tracker_in_project)
135 135 return
136 136 end
137 137 if params[:issue].is_a?(Hash)
138 138 @issue.safe_attributes = params[:issue]
139 139 @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
140 140 end
141 141 @issue.author = User.current
142 142
143 143 default_status = IssueStatus.default
144 144 unless default_status
145 145 render_error l(:error_no_default_issue_status)
146 146 return
147 147 end
148 148 @issue.status = default_status
149 149 @allowed_statuses = ([default_status] + default_status.find_new_statuses_allowed_to(User.current.roles_for_project(@project), @issue.tracker)).uniq
150 150
151 151 if request.get? || request.xhr?
152 152 @issue.start_date ||= Date.today
153 153 else
154 154 requested_status = IssueStatus.find_by_id(params[:issue][:status_id])
155 155 # Check that the user is allowed to apply the requested status
156 156 @issue.status = (@allowed_statuses.include? requested_status) ? requested_status : default_status
157 157 call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
158 158 if @issue.save
159 159 attach_files(@issue, params[:attachments])
160 160 flash[:notice] = l(:notice_successful_create)
161 161 call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
162 162 respond_to do |format|
163 163 format.html {
164 164 redirect_to(params[:continue] ? { :action => 'new', :tracker_id => @issue.tracker } :
165 165 { :action => 'show', :id => @issue })
166 166 }
167 167 format.xml { render :action => 'show', :status => :created, :location => url_for(:controller => 'issues', :action => 'show', :id => @issue) }
168 168 end
169 169 return
170 170 else
171 171 respond_to do |format|
172 172 format.html { }
173 173 format.xml { render(:xml => @issue.errors, :status => :unprocessable_entity); return }
174 174 end
175 175 end
176 176 end
177 177 @priorities = IssuePriority.all
178 178 render :layout => !request.xhr?
179 179 end
180 180
181 181 # Attributes that can be updated on workflow transition (without :edit permission)
182 182 # TODO: make it configurable (at least per role)
183 183 UPDATABLE_ATTRS_ON_TRANSITION = %w(status_id assigned_to_id fixed_version_id done_ratio) unless const_defined?(:UPDATABLE_ATTRS_ON_TRANSITION)
184 184
185 185 def edit
186 186 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
187 187 @priorities = IssuePriority.all
188 188 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
189 189 @time_entry = TimeEntry.new
190 190
191 191 @notes = params[:notes]
192 192 journal = @issue.init_journal(User.current, @notes)
193 193 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
194 194 if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
195 195 attrs = params[:issue].dup
196 196 attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
197 197 attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
198 198 @issue.safe_attributes = attrs
199 199 end
200 200
201 201 if request.get?
202 202 # nop
203 203 else
204 204 @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
205 205 @time_entry.attributes = params[:time_entry]
206 206 if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.valid?
207 207 attachments = attach_files(@issue, params[:attachments])
208 208 attachments.each {|a| journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
209 209 call_hook(:controller_issues_edit_before_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
210 210 if @issue.save
211 211 # Log spend time
212 212 if User.current.allowed_to?(:log_time, @project)
213 213 @time_entry.save
214 214 end
215 215 if !journal.new_record?
216 216 # Only send notification if something was actually changed
217 217 flash[:notice] = l(:notice_successful_update)
218 218 end
219 219 call_hook(:controller_issues_edit_after_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
220 220 respond_to do |format|
221 221 format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
222 222 format.xml { head :ok }
223 223 end
224 224 return
225 225 end
226 226 end
227 227 # failure
228 228 respond_to do |format|
229 229 format.html { }
230 230 format.xml { render :xml => @issue.errors, :status => :unprocessable_entity }
231 231 end
232 232 end
233 233 rescue ActiveRecord::StaleObjectError
234 234 # Optimistic locking exception
235 235 flash.now[:error] = l(:notice_locking_conflict)
236 236 # Remove the previously added attachments if issue was not updated
237 237 attachments.each(&:destroy)
238 238 end
239 239
240 240 def reply
241 241 journal = Journal.find(params[:journal_id]) if params[:journal_id]
242 242 if journal
243 243 user = journal.user
244 244 text = journal.notes
245 245 else
246 246 user = @issue.author
247 247 text = @issue.description
248 248 end
249 249 content = "#{ll(Setting.default_language, :text_user_wrote, user)}\\n> "
250 250 content << text.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]').gsub('"', '\"').gsub(/(\r?\n|\r\n?)/, "\\n> ") + "\\n\\n"
251 251 render(:update) { |page|
252 252 page.<< "$('notes').value = \"#{content}\";"
253 253 page.show 'update'
254 254 page << "Form.Element.focus('notes');"
255 255 page << "Element.scrollTo('update');"
256 256 page << "$('notes').scrollTop = $('notes').scrollHeight - $('notes').clientHeight;"
257 257 }
258 258 end
259 259
260 260 # Bulk edit a set of issues
261 261 def bulk_edit
262 262 if request.post?
263 263 tracker = params[:tracker_id].blank? ? nil : @project.trackers.find_by_id(params[:tracker_id])
264 264 status = params[:status_id].blank? ? nil : IssueStatus.find_by_id(params[:status_id])
265 265 priority = params[:priority_id].blank? ? nil : IssuePriority.find_by_id(params[:priority_id])
266 266 assigned_to = (params[:assigned_to_id].blank? || params[:assigned_to_id] == 'none') ? nil : User.find_by_id(params[:assigned_to_id])
267 267 category = (params[:category_id].blank? || params[:category_id] == 'none') ? nil : @project.issue_categories.find_by_id(params[:category_id])
268 268 fixed_version = (params[:fixed_version_id].blank? || params[:fixed_version_id] == 'none') ? nil : @project.shared_versions.find_by_id(params[:fixed_version_id])
269 269 custom_field_values = params[:custom_field_values] ? params[:custom_field_values].reject {|k,v| v.blank?} : nil
270 270
271 271 unsaved_issue_ids = []
272 272 @issues.each do |issue|
273 273 journal = issue.init_journal(User.current, params[:notes])
274 274 issue.tracker = tracker if tracker
275 275 issue.priority = priority if priority
276 276 issue.assigned_to = assigned_to if assigned_to || params[:assigned_to_id] == 'none'
277 277 issue.category = category if category || params[:category_id] == 'none'
278 278 issue.fixed_version = fixed_version if fixed_version || params[:fixed_version_id] == 'none'
279 279 issue.start_date = params[:start_date] unless params[:start_date].blank?
280 280 issue.due_date = params[:due_date] unless params[:due_date].blank?
281 281 issue.done_ratio = params[:done_ratio] unless params[:done_ratio].blank?
282 282 issue.custom_field_values = custom_field_values if custom_field_values && !custom_field_values.empty?
283 283 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
284 284 # Don't save any change to the issue if the user is not authorized to apply the requested status
285 285 unless (status.nil? || (issue.new_statuses_allowed_to(User.current).include?(status) && issue.status = status)) && issue.save
286 286 # Keep unsaved issue ids to display them in flash error
287 287 unsaved_issue_ids << issue.id
288 288 end
289 289 end
290 290 if unsaved_issue_ids.empty?
291 291 flash[:notice] = l(:notice_successful_update) unless @issues.empty?
292 292 else
293 293 flash[:error] = l(:notice_failed_to_save_issues, :count => unsaved_issue_ids.size,
294 294 :total => @issues.size,
295 295 :ids => '#' + unsaved_issue_ids.join(', #'))
296 296 end
297 297 redirect_back_or_default({:controller => 'issues', :action => 'index', :project_id => @project})
298 298 return
299 299 end
300 300 @available_statuses = Workflow.available_statuses(@project)
301 301 @custom_fields = @project.all_issue_custom_fields
302 302 end
303 303
304 304 def move
305 305 @copy = params[:copy_options] && params[:copy_options][:copy]
306 306 @allowed_projects = []
307 307 # find projects to which the user is allowed to move the issue
308 308 if User.current.admin?
309 309 # admin is allowed to move issues to any active (visible) project
310 310 @allowed_projects = Project.find(:all, :conditions => Project.visible_by(User.current))
311 311 else
312 312 User.current.memberships.each {|m| @allowed_projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
313 313 end
314 314 @target_project = @allowed_projects.detect {|p| p.id.to_s == params[:new_project_id]} if params[:new_project_id]
315 315 @target_project ||= @project
316 316 @trackers = @target_project.trackers
317 317 @available_statuses = Workflow.available_statuses(@project)
318 318 if request.post?
319 319 new_tracker = params[:new_tracker_id].blank? ? nil : @target_project.trackers.find_by_id(params[:new_tracker_id])
320 320 unsaved_issue_ids = []
321 321 moved_issues = []
322 322 @issues.each do |issue|
323 323 changed_attributes = {}
324 324 [:assigned_to_id, :status_id, :start_date, :due_date].each do |valid_attribute|
325 325 unless params[valid_attribute].blank?
326 326 changed_attributes[valid_attribute] = (params[valid_attribute] == 'none' ? nil : params[valid_attribute])
327 327 end
328 328 end
329 329 issue.init_journal(User.current)
330 330 if r = issue.move_to(@target_project, new_tracker, {:copy => @copy, :attributes => changed_attributes})
331 331 moved_issues << r
332 332 else
333 333 unsaved_issue_ids << issue.id
334 334 end
335 335 end
336 336 if unsaved_issue_ids.empty?
337 337 flash[:notice] = l(:notice_successful_update) unless @issues.empty?
338 338 else
339 339 flash[:error] = l(:notice_failed_to_save_issues, :count => unsaved_issue_ids.size,
340 340 :total => @issues.size,
341 341 :ids => '#' + unsaved_issue_ids.join(', #'))
342 342 end
343 343 if params[:follow]
344 344 if @issues.size == 1 && moved_issues.size == 1
345 345 redirect_to :controller => 'issues', :action => 'show', :id => moved_issues.first
346 346 else
347 347 redirect_to :controller => 'issues', :action => 'index', :project_id => (@target_project || @project)
348 348 end
349 349 else
350 350 redirect_to :controller => 'issues', :action => 'index', :project_id => @project
351 351 end
352 352 return
353 353 end
354 354 render :layout => false if request.xhr?
355 355 end
356 356
357 357 def destroy
358 358 @hours = TimeEntry.sum(:hours, :conditions => ['issue_id IN (?)', @issues]).to_f
359 359 if @hours > 0
360 360 case params[:todo]
361 361 when 'destroy'
362 362 # nothing to do
363 363 when 'nullify'
364 364 TimeEntry.update_all('issue_id = NULL', ['issue_id IN (?)', @issues])
365 365 when 'reassign'
366 366 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
367 367 if reassign_to.nil?
368 368 flash.now[:error] = l(:error_issue_not_found_in_project)
369 369 return
370 370 else
371 371 TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
372 372 end
373 373 else
374 374 unless params[:format] == 'xml'
375 375 # display the destroy form if it's a user request
376 376 return
377 377 end
378 378 end
379 379 end
380 380 @issues.each(&:destroy)
381 381 respond_to do |format|
382 382 format.html { redirect_to :action => 'index', :project_id => @project }
383 383 format.xml { head :ok }
384 384 end
385 385 end
386 386
387 387 def gantt
388 388 @gantt = Redmine::Helpers::Gantt.new(params)
389 389 retrieve_query
390 390 if @query.valid?
391 391 events = []
392 392 # Issues that have start and due dates
393 393 events += @query.issues(:include => [:tracker, :assigned_to, :priority],
394 394 :order => "start_date, due_date",
395 395 :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]
396 396 )
397 397 # Issues that don't have a due date but that are assigned to a version with a date
398 398 events += @query.issues(:include => [:tracker, :assigned_to, :priority, :fixed_version],
399 399 :order => "start_date, effective_date",
400 400 :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]
401 401 )
402 402 # Versions
403 403 events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @gantt.date_from, @gantt.date_to])
404 404
405 405 @gantt.events = events
406 406 end
407 407
408 408 basename = (@project ? "#{@project.identifier}-" : '') + 'gantt'
409 409
410 410 respond_to do |format|
411 411 format.html { render :template => "issues/gantt.rhtml", :layout => !request.xhr? }
412 412 format.png { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
413 413 format.pdf { send_data(gantt_to_pdf(@gantt, @project), :type => 'application/pdf', :filename => "#{basename}.pdf") }
414 414 end
415 415 end
416 416
417 417 def calendar
418 418 if params[:year] and params[:year].to_i > 1900
419 419 @year = params[:year].to_i
420 420 if params[:month] and params[:month].to_i > 0 and params[:month].to_i < 13
421 421 @month = params[:month].to_i
422 422 end
423 423 end
424 424 @year ||= Date.today.year
425 425 @month ||= Date.today.month
426 426
427 427 @calendar = Redmine::Helpers::Calendar.new(Date.civil(@year, @month, 1), current_language, :month)
428 428 retrieve_query
429 429 if @query.valid?
430 430 events = []
431 431 events += @query.issues(:include => [:tracker, :assigned_to, :priority],
432 432 :conditions => ["((start_date BETWEEN ? AND ?) OR (due_date BETWEEN ? AND ?))", @calendar.startdt, @calendar.enddt, @calendar.startdt, @calendar.enddt]
433 433 )
434 434 events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @calendar.startdt, @calendar.enddt])
435 435
436 436 @calendar.events = events
437 437 end
438 438
439 439 render :layout => false if request.xhr?
440 440 end
441 441
442 442 def context_menu
443 443 @issues = Issue.find_all_by_id(params[:ids], :include => :project)
444 444 if (@issues.size == 1)
445 445 @issue = @issues.first
446 446 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
447 447 end
448 448 projects = @issues.collect(&:project).compact.uniq
449 449 @project = projects.first if projects.size == 1
450 450
451 451 @can = {:edit => (@project && User.current.allowed_to?(:edit_issues, @project)),
452 452 :log_time => (@project && User.current.allowed_to?(:log_time, @project)),
453 453 :update => (@project && (User.current.allowed_to?(:edit_issues, @project) || (User.current.allowed_to?(:change_status, @project) && @allowed_statuses && !@allowed_statuses.empty?))),
454 454 :move => (@project && User.current.allowed_to?(:move_issues, @project)),
455 455 :copy => (@issue && @project.trackers.include?(@issue.tracker) && User.current.allowed_to?(:add_issues, @project)),
456 456 :delete => (@project && User.current.allowed_to?(:delete_issues, @project))
457 457 }
458 458 if @project
459 459 @assignables = @project.assignable_users
460 460 @assignables << @issue.assigned_to if @issue && @issue.assigned_to && !@assignables.include?(@issue.assigned_to)
461 461 @trackers = @project.trackers
462 462 end
463 463
464 464 @priorities = IssuePriority.all.reverse
465 465 @statuses = IssueStatus.find(:all, :order => 'position')
466 466 @back = params[:back_url] || request.env['HTTP_REFERER']
467 467
468 468 render :layout => false
469 469 end
470 470
471 471 def update_form
472 472 if params[:id].blank?
473 473 @issue = Issue.new
474 474 @issue.project = @project
475 475 else
476 476 @issue = @project.issues.visible.find(params[:id])
477 477 end
478 478 @issue.attributes = params[:issue]
479 479 @allowed_statuses = ([@issue.status] + @issue.status.find_new_statuses_allowed_to(User.current.roles_for_project(@project), @issue.tracker)).uniq
480 480 @priorities = IssuePriority.all
481 481
482 482 render :partial => 'attributes'
483 483 end
484 484
485 485 def preview
486 486 @issue = @project.issues.find_by_id(params[:id]) unless params[:id].blank?
487 487 @attachements = @issue.attachments if @issue
488 488 @text = params[:notes] || (params[:issue] ? params[:issue][:description] : nil)
489 489 render :partial => 'common/preview'
490 490 end
491 491
492 492 private
493 493 def find_issue
494 494 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
495 495 @project = @issue.project
496 496 rescue ActiveRecord::RecordNotFound
497 497 render_404
498 498 end
499 499
500 500 # Filter for bulk operations
501 501 def find_issues
502 502 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
503 503 raise ActiveRecord::RecordNotFound if @issues.empty?
504 504 projects = @issues.collect(&:project).compact.uniq
505 505 if projects.size == 1
506 506 @project = projects.first
507 507 else
508 508 # TODO: let users bulk edit/move/destroy issues from different projects
509 509 render_error 'Can not bulk edit/move/destroy issues from different projects'
510 510 return false
511 511 end
512 512 rescue ActiveRecord::RecordNotFound
513 513 render_404
514 514 end
515 515
516 516 def find_project
517 517 project_id = (params[:issue] && params[:issue][:project_id]) || params[:project_id]
518 518 @project = Project.find(project_id)
519 519 rescue ActiveRecord::RecordNotFound
520 520 render_404
521 521 end
522 522
523 523 def find_optional_project
524 524 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
525 525 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
526 526 allowed ? true : deny_access
527 527 rescue ActiveRecord::RecordNotFound
528 528 render_404
529 529 end
530 530
531 531 # Retrieve query from session or build a new query
532 532 def retrieve_query
533 533 if !params[:query_id].blank?
534 534 cond = "project_id IS NULL"
535 535 cond << " OR project_id = #{@project.id}" if @project
536 536 @query = Query.find(params[:query_id], :conditions => cond)
537 537 @query.project = @project
538 538 session[:query] = {:id => @query.id, :project_id => @query.project_id}
539 539 sort_clear
540 540 else
541 541 if api_request? || params[:set_filter] || session[:query].nil? || session[:query][:project_id] != (@project ? @project.id : nil)
542 542 # Give it a name, required to be valid
543 543 @query = Query.new(:name => "_")
544 544 @query.project = @project
545 545 if params[:fields] and params[:fields].is_a? Array
546 546 params[:fields].each do |field|
547 547 @query.add_filter(field, params[:operators][field], params[:values][field])
548 548 end
549 549 else
550 550 @query.available_filters.keys.each do |field|
551 551 @query.add_short_filter(field, params[field]) if params[field]
552 552 end
553 553 end
554 554 @query.group_by = params[:group_by]
555 555 @query.column_names = params[:query] && params[:query][:column_names]
556 556 session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names}
557 557 else
558 558 @query = Query.find_by_id(session[:query][:id]) if session[:query][:id]
559 559 @query ||= Query.new(:name => "_", :project => @project, :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names])
560 560 @query.project = @project
561 561 end
562 562 end
563 563 end
564 564
565 565 # Rescues an invalid query statement. Just in case...
566 566 def query_statement_invalid(exception)
567 567 logger.error "Query::StatementInvalid: #{exception.message}" if logger
568 568 session.delete(:query)
569 569 sort_clear
570 570 render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
571 571 end
572 572 end
@@ -1,170 +1,181
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 require 'iconv'
19 19
20 20 class Changeset < ActiveRecord::Base
21 21 belongs_to :repository
22 22 belongs_to :user
23 23 has_many :changes, :dependent => :delete_all
24 24 has_and_belongs_to_many :issues
25 25
26 26 acts_as_event :title => Proc.new {|o| "#{l(:label_revision)} #{o.revision}" + (o.short_comments.blank? ? '' : (': ' + o.short_comments))},
27 27 :description => :long_comments,
28 28 :datetime => :committed_on,
29 29 :url => Proc.new {|o| {:controller => 'repositories', :action => 'revision', :id => o.repository.project, :rev => o.revision}}
30 30
31 31 acts_as_searchable :columns => 'comments',
32 32 :include => {:repository => :project},
33 33 :project_key => "#{Repository.table_name}.project_id",
34 34 :date_column => 'committed_on'
35 35
36 36 acts_as_activity_provider :timestamp => "#{table_name}.committed_on",
37 37 :author_key => :user_id,
38 38 :find_options => {:include => [:user, {:repository => :project}]}
39 39
40 40 validates_presence_of :repository_id, :revision, :committed_on, :commit_date
41 41 validates_uniqueness_of :revision, :scope => :repository_id
42 42 validates_uniqueness_of :scmid, :scope => :repository_id, :allow_nil => true
43 43
44 named_scope :visible, lambda {|*args| { :include => {:repository => :project},
45 :conditions => Project.allowed_to_condition(args.first || User.current, :view_changesets) } }
46
44 47 def revision=(r)
45 48 write_attribute :revision, (r.nil? ? nil : r.to_s)
46 49 end
47 50
48 51 def comments=(comment)
49 52 write_attribute(:comments, Changeset.normalize_comments(comment))
50 53 end
51 54
52 55 def committed_on=(date)
53 56 self.commit_date = date
54 57 super
55 58 end
56 59
57 60 def project
58 61 repository.project
59 62 end
60 63
61 64 def author
62 65 user || committer.to_s.split('<').first
63 66 end
64 67
65 68 def before_create
66 69 self.user = repository.find_committer_user(committer)
67 70 end
68 71
69 72 def after_create
70 73 scan_comment_for_issue_ids
71 74 end
72 75 require 'pp'
73 76
74 77 def scan_comment_for_issue_ids
75 78 return if comments.blank?
76 79 # keywords used to reference issues
77 80 ref_keywords = Setting.commit_ref_keywords.downcase.split(",").collect(&:strip)
78 81 # keywords used to fix issues
79 82 fix_keywords = Setting.commit_fix_keywords.downcase.split(",").collect(&:strip)
80 83 # status and optional done ratio applied
81 84 fix_status = IssueStatus.find_by_id(Setting.commit_fix_status_id)
82 85 done_ratio = Setting.commit_fix_done_ratio.blank? ? nil : Setting.commit_fix_done_ratio.to_i
83 86
84 87 kw_regexp = (ref_keywords + fix_keywords).collect{|kw| Regexp.escape(kw)}.join("|")
85 88 return if kw_regexp.blank?
86 89
87 90 referenced_issues = []
88 91
89 92 if ref_keywords.delete('*')
90 93 # find any issue ID in the comments
91 94 target_issue_ids = []
92 95 comments.scan(%r{([\s\(\[,-]|^)#(\d+)(?=[[:punct:]]|\s|<|$)}).each { |m| target_issue_ids << m[1] }
93 referenced_issues += repository.project.issues.find_all_by_id(target_issue_ids)
96 referenced_issues += find_referenced_issues_by_id(target_issue_ids)
94 97 end
95 98
96 99 comments.scan(Regexp.new("(#{kw_regexp})[\s:]+(([\s,;&]*#?\\d+)+)", Regexp::IGNORECASE)).each do |match|
97 100 action = match[0]
98 101 target_issue_ids = match[1].scan(/\d+/)
99 target_issues = repository.project.issues.find_all_by_id(target_issue_ids)
102 target_issues = find_referenced_issues_by_id(target_issue_ids)
100 103 if fix_status && fix_keywords.include?(action.downcase)
101 104 # update status of issues
102 105 logger.debug "Issues fixed by changeset #{self.revision}: #{issue_ids.join(', ')}." if logger && logger.debug?
103 106 target_issues.each do |issue|
104 107 # the issue may have been updated by the closure of another one (eg. duplicate)
105 108 issue.reload
106 109 # don't change the status is the issue is closed
107 110 next if issue.status.is_closed?
108 111 csettext = "r#{self.revision}"
109 112 if self.scmid && (! (csettext =~ /^r[0-9]+$/))
110 113 csettext = "commit:\"#{self.scmid}\""
111 114 end
112 115 journal = issue.init_journal(user || User.anonymous, ll(Setting.default_language, :text_status_changed_by_changeset, csettext))
113 116 issue.status = fix_status
114 117 issue.done_ratio = done_ratio if done_ratio
115 118 Redmine::Hook.call_hook(:model_changeset_scan_commit_for_issue_ids_pre_issue_update,
116 119 { :changeset => self, :issue => issue })
117 120 issue.save
118 121 end
119 122 end
120 123 referenced_issues += target_issues
121 124 end
122 125
123 126 self.issues = referenced_issues.uniq
124 127 end
125 128
126 129 def short_comments
127 130 @short_comments || split_comments.first
128 131 end
129 132
130 133 def long_comments
131 134 @long_comments || split_comments.last
132 135 end
133 136
134 137 # Returns the previous changeset
135 138 def previous
136 139 @previous ||= Changeset.find(:first, :conditions => ['id < ? AND repository_id = ?', self.id, self.repository_id], :order => 'id DESC')
137 140 end
138 141
139 142 # Returns the next changeset
140 143 def next
141 144 @next ||= Changeset.find(:first, :conditions => ['id > ? AND repository_id = ?', self.id, self.repository_id], :order => 'id ASC')
142 145 end
143 146
144 147 # Strips and reencodes a commit log before insertion into the database
145 148 def self.normalize_comments(str)
146 149 to_utf8(str.to_s.strip)
147 150 end
148 151
149 152 private
150 153
154 # Finds issues that can be referenced by the commit message
155 # i.e. issues that belong to the repository project, a subproject or a parent project
156 def find_referenced_issues_by_id(ids)
157 Issue.find_all_by_id(ids, :include => :project).select {|issue|
158 project == issue.project || project.is_ancestor_of?(issue.project) || project.is_descendant_of?(issue.project)
159 }
160 end
161
151 162 def split_comments
152 163 comments =~ /\A(.+?)\r?\n(.*)$/m
153 164 @short_comments = $1 || comments
154 165 @long_comments = $2.to_s.strip
155 166 return @short_comments, @long_comments
156 167 end
157 168
158 169 def self.to_utf8(str)
159 170 return str if /\A[\r\n\t\x20-\x7e]*\Z/n.match(str) # for us-ascii
160 171 encoding = Setting.commit_logs_encoding.to_s.strip
161 172 unless encoding.blank? || encoding == 'UTF-8'
162 173 begin
163 174 return Iconv.conv('UTF-8', encoding, str)
164 175 rescue Iconv::Failure
165 176 # do nothing here
166 177 end
167 178 end
168 179 str
169 180 end
170 181 end
@@ -1,115 +1,115
1 1 <%= render :partial => 'action_menu' %>
2 2
3 3 <h2><%= @issue.tracker.name %> #<%= @issue.id %></h2>
4 4
5 5 <div class="<%= @issue.css_classes %> details">
6 6 <%= avatar(@issue.author, :size => "50") %>
7 7 <h3><%=h @issue.subject %></h3>
8 8 <p class="author">
9 9 <%= authoring @issue.created_on, @issue.author %>.
10 10 <% if @issue.created_on != @issue.updated_on %>
11 11 <%= l(:label_updated_time, time_tag(@issue.updated_on)) %>.
12 12 <% end %>
13 13 </p>
14 14
15 15 <table class="attributes">
16 16 <tr>
17 17 <th class="status"><%=l(:field_status)%>:</th><td class="status"><%= @issue.status.name %></td>
18 18 <th class="start-date"><%=l(:field_start_date)%>:</th><td class="start-date"><%= format_date(@issue.start_date) %></td>
19 19 </tr>
20 20 <tr>
21 21 <th class="priority"><%=l(:field_priority)%>:</th><td class="priority"><%= @issue.priority.name %></td>
22 22 <th class="due-date"><%=l(:field_due_date)%>:</th><td class="due-date"><%= format_date(@issue.due_date) %></td>
23 23 </tr>
24 24 <tr>
25 25 <th class="assigned-to"><%=l(:field_assigned_to)%>:</th><td class="assigned-to"><%= avatar(@issue.assigned_to, :size => "14") %><%= @issue.assigned_to ? link_to_user(@issue.assigned_to) : "-" %></td>
26 26 <th class="progress"><%=l(:field_done_ratio)%>:</th><td class="progress"><%= progress_bar @issue.done_ratio, :width => '80px', :legend => "#{@issue.done_ratio}%" %></td>
27 27 </tr>
28 28 <tr>
29 29 <th class="category"><%=l(:field_category)%>:</th><td class="category"><%=h @issue.category ? @issue.category.name : "-" %></td>
30 30 <% if User.current.allowed_to?(:view_time_entries, @project) %>
31 31 <th class="spent-time"><%=l(:label_spent_time)%>:</th>
32 32 <td class="spent-time"><%= @issue.spent_hours > 0 ? (link_to l_hours(@issue.spent_hours), {:controller => 'timelog', :action => 'details', :project_id => @project, :issue_id => @issue}) : "-" %></td>
33 33 <% end %>
34 34 </tr>
35 35 <tr>
36 36 <th class="fixed-version"><%=l(:field_fixed_version)%>:</th><td class="fixed-version"><%= @issue.fixed_version ? link_to_version(@issue.fixed_version) : "-" %></td>
37 37 <% if @issue.estimated_hours %>
38 38 <th class="estimated-hours"><%=l(:field_estimated_hours)%>:</th><td class="estimated-hours"><%= l_hours(@issue.estimated_hours) %></td>
39 39 <% end %>
40 40 </tr>
41 41 <%= render_custom_fields_rows(@issue) %>
42 42 <%= call_hook(:view_issues_show_details_bottom, :issue => @issue) %>
43 43 </table>
44 44 <hr />
45 45
46 46 <div class="contextual">
47 47 <%= link_to_remote_if_authorized(l(:button_quote), { :url => {:action => 'reply', :id => @issue} }, :class => 'icon icon-comment') unless @issue.description.blank? %>
48 48 </div>
49 49
50 50 <p><strong><%=l(:field_description)%></strong></p>
51 51 <div class="wiki">
52 52 <%= textilizable @issue, :description, :attachments => @issue.attachments %>
53 53 </div>
54 54
55 55 <%= link_to_attachments @issue %>
56 56
57 57 <%= call_hook(:view_issues_show_description_bottom, :issue => @issue) %>
58 58
59 59 <% if authorize_for('issue_relations', 'new') || @issue.relations.any? %>
60 60 <hr />
61 61 <div id="relations">
62 62 <%= render :partial => 'relations' %>
63 63 </div>
64 64 <% end %>
65 65
66 66 <% if User.current.allowed_to?(:add_issue_watchers, @project) ||
67 67 (@issue.watchers.any? && User.current.allowed_to?(:view_issue_watchers, @project)) %>
68 68 <hr />
69 69 <div id="watchers">
70 70 <%= render :partial => 'watchers/watchers', :locals => {:watched => @issue} %>
71 71 </div>
72 72 <% end %>
73 73
74 74 </div>
75 75
76 <% if @changesets.any? && User.current.allowed_to?(:view_changesets, @project) %>
76 <% if @changesets.any? %>
77 77 <div id="issue-changesets">
78 78 <h3><%=l(:label_associated_revisions)%></h3>
79 79 <%= render :partial => 'changesets', :locals => { :changesets => @changesets} %>
80 80 </div>
81 81 <% end %>
82 82
83 83 <% if @journals.any? %>
84 84 <div id="history">
85 85 <h3><%=l(:label_history)%></h3>
86 86 <%= render :partial => 'history', :locals => { :journals => @journals } %>
87 87 </div>
88 88 <% end %>
89 89
90 90 <%= render :partial => 'action_menu', :locals => {:replace_watcher => 'watcher2' } %>
91 91
92 92 <div style="clear: both;"></div>
93 93
94 94 <% if authorize_for('issues', 'edit') %>
95 95 <div id="update" style="display:none;">
96 96 <h3><%= l(:button_update) %></h3>
97 97 <%= render :partial => 'edit' %>
98 98 </div>
99 99 <% end %>
100 100
101 101 <% other_formats_links do |f| %>
102 102 <%= f.link_to 'Atom', :url => {:key => User.current.rss_key} %>
103 103 <%= f.link_to 'PDF' %>
104 104 <% end %>
105 105
106 106 <% html_title "#{@issue.tracker.name} ##{@issue.id}: #{@issue.subject}" %>
107 107
108 108 <% content_for :sidebar do %>
109 109 <%= render :partial => 'issues/sidebar' %>
110 110 <% end %>
111 111
112 112 <% content_for :header_tags do %>
113 113 <%= auto_discovery_link_tag(:atom, {:format => 'atom', :key => User.current.rss_key}, :title => "#{@issue.project} - #{@issue.tracker} ##{@issue.id}: #{@issue.subject}") %>
114 114 <%= stylesheet_link_tag 'scm' %>
115 115 <% end %>
@@ -1,97 +1,120
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 ChangesetTest < ActiveSupport::TestCase
21 21 fixtures :projects, :repositories, :issues, :issue_statuses, :changesets, :changes, :issue_categories, :enumerations, :custom_fields, :custom_values, :users, :members, :member_roles, :trackers
22 22
23 23 def setup
24 24 end
25 25
26 26 def test_ref_keywords_any
27 27 ActionMailer::Base.deliveries.clear
28 28 Setting.commit_fix_status_id = IssueStatus.find(:first, :conditions => ["is_closed = ?", true]).id
29 29 Setting.commit_fix_done_ratio = '90'
30 30 Setting.commit_ref_keywords = '*'
31 31 Setting.commit_fix_keywords = 'fixes , closes'
32 32
33 33 c = Changeset.new(:repository => Project.find(1).repository,
34 34 :committed_on => Time.now,
35 35 :comments => 'New commit (#2). Fixes #1')
36 36 c.scan_comment_for_issue_ids
37 37
38 38 assert_equal [1, 2], c.issue_ids.sort
39 39 fixed = Issue.find(1)
40 40 assert fixed.closed?
41 41 assert_equal 90, fixed.done_ratio
42 42 assert_equal 1, ActionMailer::Base.deliveries.size
43 43 end
44 44
45 45 def test_ref_keywords_any_line_start
46 46 Setting.commit_ref_keywords = '*'
47 47
48 48 c = Changeset.new(:repository => Project.find(1).repository,
49 49 :committed_on => Time.now,
50 50 :comments => '#1 is the reason of this commit')
51 51 c.scan_comment_for_issue_ids
52 52
53 53 assert_equal [1], c.issue_ids.sort
54 54 end
55 55
56 56 def test_ref_keywords_allow_brackets_around_a_issue_number
57 57 Setting.commit_ref_keywords = '*'
58 58
59 59 c = Changeset.new(:repository => Project.find(1).repository,
60 60 :committed_on => Time.now,
61 61 :comments => '[#1] Worked on this issue')
62 62 c.scan_comment_for_issue_ids
63 63
64 64 assert_equal [1], c.issue_ids.sort
65 65 end
66 66
67 67 def test_ref_keywords_allow_brackets_around_multiple_issue_numbers
68 68 Setting.commit_ref_keywords = '*'
69 69
70 70 c = Changeset.new(:repository => Project.find(1).repository,
71 71 :committed_on => Time.now,
72 72 :comments => '[#1 #2, #3] Worked on these')
73 73 c.scan_comment_for_issue_ids
74 74
75 75 assert_equal [1,2,3], c.issue_ids.sort
76 76 end
77
78 def test_commit_referencing_a_subproject_issue
79 c = Changeset.new(:repository => Project.find(1).repository,
80 :committed_on => Time.now,
81 :comments => 'refs #5, a subproject issue')
82 c.scan_comment_for_issue_ids
83
84 assert_equal [5], c.issue_ids.sort
85 assert c.issues.first.project != c.project
86 end
87
88 def test_commit_referencing_a_parent_project_issue
89 # repository of child project
90 r = Repository::Subversion.create!(:project => Project.find(3), :url => 'svn://localhost/test')
91
92 c = Changeset.new(:repository => r,
93 :committed_on => Time.now,
94 :comments => 'refs #2, an issue of a parent project')
95 c.scan_comment_for_issue_ids
96
97 assert_equal [2], c.issue_ids.sort
98 assert c.issues.first.project != c.project
99 end
77 100
78 101 def test_previous
79 102 changeset = Changeset.find_by_revision('3')
80 103 assert_equal Changeset.find_by_revision('2'), changeset.previous
81 104 end
82 105
83 106 def test_previous_nil
84 107 changeset = Changeset.find_by_revision('1')
85 108 assert_nil changeset.previous
86 109 end
87 110
88 111 def test_next
89 112 changeset = Changeset.find_by_revision('2')
90 113 assert_equal Changeset.find_by_revision('3'), changeset.next
91 114 end
92 115
93 116 def test_next_nil
94 117 changeset = Changeset.find_by_revision('10')
95 118 assert_nil changeset.next
96 119 end
97 120 end
General Comments 0
You need to be logged in to leave comments. Login now