##// END OF EJS Templates
Version sharing (#465) + optional inclusion of subprojects in the roadmap view (#2666)....
Jean-Philippe Lang -
r3009:5f8e9d711820
parent child
Show More
@@ -0,0 +1,10
1 class AddVersionsSharing < ActiveRecord::Migration
2 def self.up
3 add_column :versions, :sharing, :string, :default => 'none', :null => false
4 add_index :versions, :sharing
5 end
6
7 def self.down
8 remove_column :versions, :sharing
9 end
10 end
@@ -0,0 +1,63
1 # Redmine - project management software
2 # Copyright (C) 2006-2009 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require File.dirname(__FILE__) + '/../../test_helper'
19
20 class ProjectsHelperTest < HelperTestCase
21 include ApplicationHelper
22 include ProjectsHelper
23
24 fixtures :all
25
26 def setup
27 super
28 set_language_if_valid('en')
29 User.current = nil
30 end
31
32 def test_link_to_version_within_project
33 @project = Project.find(2)
34 User.current = User.find(1)
35 assert_equal '<a href="/versions/show/5">Alpha</a>', link_to_version(Version.find(5))
36 end
37
38 def test_link_to_version
39 User.current = User.find(1)
40 assert_equal '<a href="/versions/show/5">OnlineStore - Alpha</a>', link_to_version(Version.find(5))
41 end
42
43 def test_link_to_private_version
44 assert_equal 'OnlineStore - Alpha', link_to_version(Version.find(5))
45 end
46
47 def test_link_to_version_invalid_version
48 assert_equal '', link_to_version(Object)
49 end
50
51 def test_format_version_name_within_project
52 @project = Project.find(1)
53 assert_equal "0.1", format_version_name(Version.find(1))
54 end
55
56 def test_format_version_name
57 assert_equal "eCookbook - 0.1", format_version_name(Version.find(1))
58 end
59
60 def test_format_version_name_for_system_version
61 assert_equal "OnlineStore - Systemwide visible version", format_version_name(Version.find(7))
62 end
63 end
@@ -1,537 +1,537
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 helper :sort
44 44 include SortHelper
45 45 include IssuesHelper
46 46 helper :timelog
47 47 include Redmine::Export::PDF
48 48
49 49 verify :method => :post,
50 50 :only => :destroy,
51 51 :render => { :nothing => true, :status => :method_not_allowed }
52 52
53 53 def index
54 54 retrieve_query
55 55 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
56 56 sort_update({'id' => "#{Issue.table_name}.id"}.merge(@query.available_columns.inject({}) {|h, c| h[c.name.to_s] = c.sortable; h}))
57 57
58 58 if @query.valid?
59 59 limit = per_page_option
60 60 respond_to do |format|
61 61 format.html { }
62 62 format.atom { limit = Setting.feeds_limit.to_i }
63 63 format.csv { limit = Setting.issues_export_limit.to_i }
64 64 format.pdf { limit = Setting.issues_export_limit.to_i }
65 65 end
66 66
67 67 @issue_count = @query.issue_count
68 68 @issue_pages = Paginator.new self, @issue_count, limit, params['page']
69 69 @issues = @query.issues(:include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
70 70 :order => sort_clause,
71 71 :offset => @issue_pages.current.offset,
72 72 :limit => limit)
73 73 @issue_count_by_group = @query.issue_count_by_group
74 74
75 75 respond_to do |format|
76 76 format.html { render :template => 'issues/index.rhtml', :layout => !request.xhr? }
77 77 format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
78 78 format.csv { send_data(issues_to_csv(@issues, @project), :type => 'text/csv; header=present', :filename => 'export.csv') }
79 79 format.pdf { send_data(issues_to_pdf(@issues, @project, @query), :type => 'application/pdf', :filename => 'export.pdf') }
80 80 end
81 81 else
82 82 # Send html if the query is not valid
83 83 render(:template => 'issues/index.rhtml', :layout => !request.xhr?)
84 84 end
85 85 rescue ActiveRecord::RecordNotFound
86 86 render_404
87 87 end
88 88
89 89 def changes
90 90 retrieve_query
91 91 sort_init 'id', 'desc'
92 92 sort_update({'id' => "#{Issue.table_name}.id"}.merge(@query.available_columns.inject({}) {|h, c| h[c.name.to_s] = c.sortable; h}))
93 93
94 94 if @query.valid?
95 95 @journals = @query.journals(:order => "#{Journal.table_name}.created_on DESC",
96 96 :limit => 25)
97 97 end
98 98 @title = (@project ? @project.name : Setting.app_title) + ": " + (@query.new_record? ? l(:label_changes_details) : @query.name)
99 99 render :layout => false, :content_type => 'application/atom+xml'
100 100 rescue ActiveRecord::RecordNotFound
101 101 render_404
102 102 end
103 103
104 104 def show
105 105 @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
106 106 @journals.each_with_index {|j,i| j.indice = i+1}
107 107 @journals.reverse! if User.current.wants_comments_in_reverse_order?
108 108 @changesets = @issue.changesets
109 109 @changesets.reverse! if User.current.wants_comments_in_reverse_order?
110 110 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
111 111 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
112 112 @priorities = IssuePriority.all
113 113 @time_entry = TimeEntry.new
114 114 respond_to do |format|
115 115 format.html { render :template => 'issues/show.rhtml' }
116 116 format.atom { render :action => 'changes', :layout => false, :content_type => 'application/atom+xml' }
117 117 format.pdf { send_data(issue_to_pdf(@issue), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
118 118 end
119 119 end
120 120
121 121 # Add a new issue
122 122 # The new issue will be created from an existing one if copy_from parameter is given
123 123 def new
124 124 @issue = Issue.new
125 125 @issue.copy_from(params[:copy_from]) if params[:copy_from]
126 126 @issue.project = @project
127 127 # Tracker must be set before custom field values
128 128 @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
129 129 if @issue.tracker.nil?
130 130 render_error l(:error_no_tracker_in_project)
131 131 return
132 132 end
133 133 if params[:issue].is_a?(Hash)
134 134 @issue.attributes = params[:issue]
135 135 @issue.watcher_user_ids = params[:issue]['watcher_user_ids'] if User.current.allowed_to?(:add_issue_watchers, @project)
136 136 end
137 137 @issue.author = User.current
138 138
139 139 default_status = IssueStatus.default
140 140 unless default_status
141 141 render_error l(:error_no_default_issue_status)
142 142 return
143 143 end
144 144 @issue.status = default_status
145 145 @allowed_statuses = ([default_status] + default_status.find_new_statuses_allowed_to(User.current.roles_for_project(@project), @issue.tracker)).uniq
146 146
147 147 if request.get? || request.xhr?
148 148 @issue.start_date ||= Date.today
149 149 else
150 150 requested_status = IssueStatus.find_by_id(params[:issue][:status_id])
151 151 # Check that the user is allowed to apply the requested status
152 152 @issue.status = (@allowed_statuses.include? requested_status) ? requested_status : default_status
153 153 if @issue.save
154 154 attach_files(@issue, params[:attachments])
155 155 flash[:notice] = l(:notice_successful_create)
156 156 call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
157 157 redirect_to(params[:continue] ? { :action => 'new', :tracker_id => @issue.tracker } :
158 158 { :action => 'show', :id => @issue })
159 159 return
160 160 end
161 161 end
162 162 @priorities = IssuePriority.all
163 163 render :layout => !request.xhr?
164 164 end
165 165
166 166 # Attributes that can be updated on workflow transition (without :edit permission)
167 167 # TODO: make it configurable (at least per role)
168 168 UPDATABLE_ATTRS_ON_TRANSITION = %w(status_id assigned_to_id fixed_version_id done_ratio) unless const_defined?(:UPDATABLE_ATTRS_ON_TRANSITION)
169 169
170 170 def edit
171 171 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
172 172 @priorities = IssuePriority.all
173 173 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
174 174 @time_entry = TimeEntry.new
175 175
176 176 @notes = params[:notes]
177 177 journal = @issue.init_journal(User.current, @notes)
178 178 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
179 179 if (@edit_allowed || !@allowed_statuses.empty?) && params[:issue]
180 180 attrs = params[:issue].dup
181 181 attrs.delete_if {|k,v| !UPDATABLE_ATTRS_ON_TRANSITION.include?(k) } unless @edit_allowed
182 182 attrs.delete(:status_id) unless @allowed_statuses.detect {|s| s.id.to_s == attrs[:status_id].to_s}
183 183 @issue.attributes = attrs
184 184 end
185 185
186 186 if request.post?
187 187 @time_entry = TimeEntry.new(:project => @project, :issue => @issue, :user => User.current, :spent_on => Date.today)
188 188 @time_entry.attributes = params[:time_entry]
189 189 attachments = attach_files(@issue, params[:attachments])
190 190 attachments.each {|a| journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
191 191
192 192 call_hook(:controller_issues_edit_before_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
193 193
194 194 if (@time_entry.hours.nil? || @time_entry.valid?) && @issue.save
195 195 # Log spend time
196 196 if User.current.allowed_to?(:log_time, @project)
197 197 @time_entry.save
198 198 end
199 199 if !journal.new_record?
200 200 # Only send notification if something was actually changed
201 201 flash[:notice] = l(:notice_successful_update)
202 202 end
203 203 call_hook(:controller_issues_edit_after_save, { :params => params, :issue => @issue, :time_entry => @time_entry, :journal => journal})
204 204 redirect_to(params[:back_to] || {:action => 'show', :id => @issue})
205 205 end
206 206 end
207 207 rescue ActiveRecord::StaleObjectError
208 208 # Optimistic locking exception
209 209 flash.now[:error] = l(:notice_locking_conflict)
210 210 # Remove the previously added attachments if issue was not updated
211 211 attachments.each(&:destroy)
212 212 end
213 213
214 214 def reply
215 215 journal = Journal.find(params[:journal_id]) if params[:journal_id]
216 216 if journal
217 217 user = journal.user
218 218 text = journal.notes
219 219 else
220 220 user = @issue.author
221 221 text = @issue.description
222 222 end
223 223 content = "#{ll(Setting.default_language, :text_user_wrote, user)}\\n> "
224 224 content << text.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]').gsub('"', '\"').gsub(/(\r?\n|\r\n?)/, "\\n> ") + "\\n\\n"
225 225 render(:update) { |page|
226 226 page.<< "$('notes').value = \"#{content}\";"
227 227 page.show 'update'
228 228 page << "Form.Element.focus('notes');"
229 229 page << "Element.scrollTo('update');"
230 230 page << "$('notes').scrollTop = $('notes').scrollHeight - $('notes').clientHeight;"
231 231 }
232 232 end
233 233
234 234 # Bulk edit a set of issues
235 235 def bulk_edit
236 236 if request.post?
237 237 tracker = params[:tracker_id].blank? ? nil : @project.trackers.find_by_id(params[:tracker_id])
238 238 status = params[:status_id].blank? ? nil : IssueStatus.find_by_id(params[:status_id])
239 239 priority = params[:priority_id].blank? ? nil : IssuePriority.find_by_id(params[:priority_id])
240 240 assigned_to = (params[:assigned_to_id].blank? || params[:assigned_to_id] == 'none') ? nil : User.find_by_id(params[:assigned_to_id])
241 241 category = (params[:category_id].blank? || params[:category_id] == 'none') ? nil : @project.issue_categories.find_by_id(params[:category_id])
242 fixed_version = (params[:fixed_version_id].blank? || params[:fixed_version_id] == 'none') ? nil : @project.versions.find_by_id(params[:fixed_version_id])
242 fixed_version = (params[:fixed_version_id].blank? || params[:fixed_version_id] == 'none') ? nil : @project.shared_versions.find_by_id(params[:fixed_version_id])
243 243 custom_field_values = params[:custom_field_values] ? params[:custom_field_values].reject {|k,v| v.blank?} : nil
244 244
245 245 unsaved_issue_ids = []
246 246 @issues.each do |issue|
247 247 journal = issue.init_journal(User.current, params[:notes])
248 248 issue.tracker = tracker if tracker
249 249 issue.priority = priority if priority
250 250 issue.assigned_to = assigned_to if assigned_to || params[:assigned_to_id] == 'none'
251 251 issue.category = category if category || params[:category_id] == 'none'
252 252 issue.fixed_version = fixed_version if fixed_version || params[:fixed_version_id] == 'none'
253 253 issue.start_date = params[:start_date] unless params[:start_date].blank?
254 254 issue.due_date = params[:due_date] unless params[:due_date].blank?
255 255 issue.done_ratio = params[:done_ratio] unless params[:done_ratio].blank?
256 256 issue.custom_field_values = custom_field_values if custom_field_values && !custom_field_values.empty?
257 257 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
258 258 # Don't save any change to the issue if the user is not authorized to apply the requested status
259 259 unless (status.nil? || (issue.new_statuses_allowed_to(User.current).include?(status) && issue.status = status)) && issue.save
260 260 # Keep unsaved issue ids to display them in flash error
261 261 unsaved_issue_ids << issue.id
262 262 end
263 263 end
264 264 if unsaved_issue_ids.empty?
265 265 flash[:notice] = l(:notice_successful_update) unless @issues.empty?
266 266 else
267 267 flash[:error] = l(:notice_failed_to_save_issues, :count => unsaved_issue_ids.size,
268 268 :total => @issues.size,
269 269 :ids => '#' + unsaved_issue_ids.join(', #'))
270 270 end
271 271 redirect_to(params[:back_to] || {:controller => 'issues', :action => 'index', :project_id => @project})
272 272 return
273 273 end
274 274 @available_statuses = Workflow.available_statuses(@project)
275 275 @custom_fields = @project.issue_custom_fields.select {|f| f.field_format == 'list'}
276 276 end
277 277
278 278 def move
279 279 @copy = params[:copy_options] && params[:copy_options][:copy]
280 280 @allowed_projects = []
281 281 # find projects to which the user is allowed to move the issue
282 282 if User.current.admin?
283 283 # admin is allowed to move issues to any active (visible) project
284 284 @allowed_projects = Project.find(:all, :conditions => Project.visible_by(User.current))
285 285 else
286 286 User.current.memberships.each {|m| @allowed_projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
287 287 end
288 288 @target_project = @allowed_projects.detect {|p| p.id.to_s == params[:new_project_id]} if params[:new_project_id]
289 289 @target_project ||= @project
290 290 @trackers = @target_project.trackers
291 291 @available_statuses = Workflow.available_statuses(@project)
292 292 if request.post?
293 293 new_tracker = params[:new_tracker_id].blank? ? nil : @target_project.trackers.find_by_id(params[:new_tracker_id])
294 294 unsaved_issue_ids = []
295 295 moved_issues = []
296 296 @issues.each do |issue|
297 297 changed_attributes = {}
298 298 [:assigned_to_id, :status_id, :start_date, :due_date].each do |valid_attribute|
299 299 changed_attributes[valid_attribute] = params[valid_attribute] if params[valid_attribute]
300 300 end
301 301 issue.init_journal(User.current)
302 302 if r = issue.move_to(@target_project, new_tracker, {:copy => @copy, :attributes => changed_attributes})
303 303 moved_issues << r
304 304 else
305 305 unsaved_issue_ids << issue.id
306 306 end
307 307 end
308 308 if unsaved_issue_ids.empty?
309 309 flash[:notice] = l(:notice_successful_update) unless @issues.empty?
310 310 else
311 311 flash[:error] = l(:notice_failed_to_save_issues, :count => unsaved_issue_ids.size,
312 312 :total => @issues.size,
313 313 :ids => '#' + unsaved_issue_ids.join(', #'))
314 314 end
315 315 if params[:follow]
316 316 if @issues.size == 1 && moved_issues.size == 1
317 317 redirect_to :controller => 'issues', :action => 'show', :id => moved_issues.first
318 318 else
319 319 redirect_to :controller => 'issues', :action => 'index', :project_id => (@target_project || @project)
320 320 end
321 321 else
322 322 redirect_to :controller => 'issues', :action => 'index', :project_id => @project
323 323 end
324 324 return
325 325 end
326 326 render :layout => false if request.xhr?
327 327 end
328 328
329 329 def destroy
330 330 @hours = TimeEntry.sum(:hours, :conditions => ['issue_id IN (?)', @issues]).to_f
331 331 if @hours > 0
332 332 case params[:todo]
333 333 when 'destroy'
334 334 # nothing to do
335 335 when 'nullify'
336 336 TimeEntry.update_all('issue_id = NULL', ['issue_id IN (?)', @issues])
337 337 when 'reassign'
338 338 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
339 339 if reassign_to.nil?
340 340 flash.now[:error] = l(:error_issue_not_found_in_project)
341 341 return
342 342 else
343 343 TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
344 344 end
345 345 else
346 346 # display the destroy form
347 347 return
348 348 end
349 349 end
350 350 @issues.each(&:destroy)
351 351 redirect_to :action => 'index', :project_id => @project
352 352 end
353 353
354 354 def gantt
355 355 @gantt = Redmine::Helpers::Gantt.new(params)
356 356 retrieve_query
357 357 if @query.valid?
358 358 events = []
359 359 # Issues that have start and due dates
360 360 events += @query.issues(:include => [:tracker, :assigned_to, :priority],
361 361 :order => "start_date, due_date",
362 362 :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]
363 363 )
364 364 # Issues that don't have a due date but that are assigned to a version with a date
365 365 events += @query.issues(:include => [:tracker, :assigned_to, :priority, :fixed_version],
366 366 :order => "start_date, effective_date",
367 367 :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]
368 368 )
369 369 # Versions
370 370 events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @gantt.date_from, @gantt.date_to])
371 371
372 372 @gantt.events = events
373 373 end
374 374
375 375 basename = (@project ? "#{@project.identifier}-" : '') + 'gantt'
376 376
377 377 respond_to do |format|
378 378 format.html { render :template => "issues/gantt.rhtml", :layout => !request.xhr? }
379 379 format.png { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
380 380 format.pdf { send_data(gantt_to_pdf(@gantt, @project), :type => 'application/pdf', :filename => "#{basename}.pdf") }
381 381 end
382 382 end
383 383
384 384 def calendar
385 385 if params[:year] and params[:year].to_i > 1900
386 386 @year = params[:year].to_i
387 387 if params[:month] and params[:month].to_i > 0 and params[:month].to_i < 13
388 388 @month = params[:month].to_i
389 389 end
390 390 end
391 391 @year ||= Date.today.year
392 392 @month ||= Date.today.month
393 393
394 394 @calendar = Redmine::Helpers::Calendar.new(Date.civil(@year, @month, 1), current_language, :month)
395 395 retrieve_query
396 396 if @query.valid?
397 397 events = []
398 398 events += @query.issues(:include => [:tracker, :assigned_to, :priority],
399 399 :conditions => ["((start_date BETWEEN ? AND ?) OR (due_date BETWEEN ? AND ?))", @calendar.startdt, @calendar.enddt, @calendar.startdt, @calendar.enddt]
400 400 )
401 401 events += @query.versions(:conditions => ["effective_date BETWEEN ? AND ?", @calendar.startdt, @calendar.enddt])
402 402
403 403 @calendar.events = events
404 404 end
405 405
406 406 render :layout => false if request.xhr?
407 407 end
408 408
409 409 def context_menu
410 410 @issues = Issue.find_all_by_id(params[:ids], :include => :project)
411 411 if (@issues.size == 1)
412 412 @issue = @issues.first
413 413 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
414 414 end
415 415 projects = @issues.collect(&:project).compact.uniq
416 416 @project = projects.first if projects.size == 1
417 417
418 418 @can = {:edit => (@project && User.current.allowed_to?(:edit_issues, @project)),
419 419 :log_time => (@project && User.current.allowed_to?(:log_time, @project)),
420 420 :update => (@project && (User.current.allowed_to?(:edit_issues, @project) || (User.current.allowed_to?(:change_status, @project) && @allowed_statuses && !@allowed_statuses.empty?))),
421 421 :move => (@project && User.current.allowed_to?(:move_issues, @project)),
422 422 :copy => (@issue && @project.trackers.include?(@issue.tracker) && User.current.allowed_to?(:add_issues, @project)),
423 423 :delete => (@project && User.current.allowed_to?(:delete_issues, @project))
424 424 }
425 425 if @project
426 426 @assignables = @project.assignable_users
427 427 @assignables << @issue.assigned_to if @issue && @issue.assigned_to && !@assignables.include?(@issue.assigned_to)
428 428 @trackers = @project.trackers
429 429 end
430 430
431 431 @priorities = IssuePriority.all.reverse
432 432 @statuses = IssueStatus.find(:all, :order => 'position')
433 433 @back = params[:back_url] || request.env['HTTP_REFERER']
434 434
435 435 render :layout => false
436 436 end
437 437
438 438 def update_form
439 439 if params[:id]
440 440 @issue = @project.issues.visible.find(params[:id])
441 441 else
442 442 @issue = Issue.new
443 443 @issue.project = @project
444 444 end
445 445 @issue.attributes = params[:issue]
446 446 @allowed_statuses = ([@issue.status] + @issue.status.find_new_statuses_allowed_to(User.current.roles_for_project(@project), @issue.tracker)).uniq
447 447 @priorities = IssuePriority.all
448 448
449 449 render :partial => 'attributes'
450 450 end
451 451
452 452 def preview
453 453 @issue = @project.issues.find_by_id(params[:id]) unless params[:id].blank?
454 454 @attachements = @issue.attachments if @issue
455 455 @text = params[:notes] || (params[:issue] ? params[:issue][:description] : nil)
456 456 render :partial => 'common/preview'
457 457 end
458 458
459 459 private
460 460 def find_issue
461 461 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
462 462 @project = @issue.project
463 463 rescue ActiveRecord::RecordNotFound
464 464 render_404
465 465 end
466 466
467 467 # Filter for bulk operations
468 468 def find_issues
469 469 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
470 470 raise ActiveRecord::RecordNotFound if @issues.empty?
471 471 projects = @issues.collect(&:project).compact.uniq
472 472 if projects.size == 1
473 473 @project = projects.first
474 474 else
475 475 # TODO: let users bulk edit/move/destroy issues from different projects
476 476 render_error 'Can not bulk edit/move/destroy issues from different projects' and return false
477 477 end
478 478 rescue ActiveRecord::RecordNotFound
479 479 render_404
480 480 end
481 481
482 482 def find_project
483 483 @project = Project.find(params[:project_id])
484 484 rescue ActiveRecord::RecordNotFound
485 485 render_404
486 486 end
487 487
488 488 def find_optional_project
489 489 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
490 490 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
491 491 allowed ? true : deny_access
492 492 rescue ActiveRecord::RecordNotFound
493 493 render_404
494 494 end
495 495
496 496 # Retrieve query from session or build a new query
497 497 def retrieve_query
498 498 if !params[:query_id].blank?
499 499 cond = "project_id IS NULL"
500 500 cond << " OR project_id = #{@project.id}" if @project
501 501 @query = Query.find(params[:query_id], :conditions => cond)
502 502 @query.project = @project
503 503 session[:query] = {:id => @query.id, :project_id => @query.project_id}
504 504 sort_clear
505 505 else
506 506 if params[:set_filter] || session[:query].nil? || session[:query][:project_id] != (@project ? @project.id : nil)
507 507 # Give it a name, required to be valid
508 508 @query = Query.new(:name => "_")
509 509 @query.project = @project
510 510 if params[:fields] and params[:fields].is_a? Array
511 511 params[:fields].each do |field|
512 512 @query.add_filter(field, params[:operators][field], params[:values][field])
513 513 end
514 514 else
515 515 @query.available_filters.keys.each do |field|
516 516 @query.add_short_filter(field, params[field]) if params[field]
517 517 end
518 518 end
519 519 @query.group_by = params[:group_by]
520 520 @query.column_names = params[:query] && params[:query][:column_names]
521 521 session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names}
522 522 else
523 523 @query = Query.find_by_id(session[:query][:id]) if session[:query][:id]
524 524 @query ||= Query.new(:name => "_", :project => @project, :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names])
525 525 @query.project = @project
526 526 end
527 527 end
528 528 end
529 529
530 530 # Rescues an invalid query statement. Just in case...
531 531 def query_statement_invalid(exception)
532 532 logger.error "Query::StatementInvalid: #{exception.message}" if logger
533 533 session.delete(:query)
534 534 sort_clear
535 535 render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
536 536 end
537 537 end
@@ -1,359 +1,406
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2009 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 ProjectsController < ApplicationController
19 19 menu_item :overview
20 20 menu_item :activity, :only => :activity
21 21 menu_item :roadmap, :only => :roadmap
22 22 menu_item :files, :only => [:list_files, :add_file]
23 23 menu_item :settings, :only => :settings
24 24 menu_item :issues, :only => [:changelog]
25 25
26 26 before_filter :find_project, :except => [ :index, :list, :add, :copy, :activity ]
27 27 before_filter :find_optional_project, :only => :activity
28 28 before_filter :authorize, :except => [ :index, :list, :add, :copy, :archive, :unarchive, :destroy, :activity ]
29 29 before_filter :authorize_global, :only => :add
30 30 before_filter :require_admin, :only => [ :copy, :archive, :unarchive, :destroy ]
31 31 accept_key_auth :activity
32 32
33 33 after_filter :only => [:add, :edit, :archive, :unarchive, :destroy] do |controller|
34 34 if controller.request.post?
35 35 controller.send :expire_action, :controller => 'welcome', :action => 'robots.txt'
36 36 end
37 37 end
38 38
39 39 helper :sort
40 40 include SortHelper
41 41 helper :custom_fields
42 42 include CustomFieldsHelper
43 43 helper :issues
44 44 helper IssuesHelper
45 45 helper :queries
46 46 include QueriesHelper
47 47 helper :repositories
48 48 include RepositoriesHelper
49 49 include ProjectsHelper
50 50
51 51 # Lists visible projects
52 52 def index
53 53 respond_to do |format|
54 54 format.html {
55 55 @projects = Project.visible.find(:all, :order => 'lft')
56 56 }
57 57 format.atom {
58 58 projects = Project.visible.find(:all, :order => 'created_on DESC',
59 59 :limit => Setting.feeds_limit.to_i)
60 60 render_feed(projects, :title => "#{Setting.app_title}: #{l(:label_project_latest)}")
61 61 }
62 62 end
63 63 end
64 64
65 65 # Add a new project
66 66 def add
67 67 @issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
68 68 @trackers = Tracker.all
69 69 @project = Project.new(params[:project])
70 70 if request.get?
71 71 @project.identifier = Project.next_identifier if Setting.sequential_project_identifiers?
72 72 @project.trackers = Tracker.all
73 73 @project.is_public = Setting.default_projects_public?
74 74 @project.enabled_module_names = Setting.default_projects_modules
75 75 else
76 76 @project.enabled_module_names = params[:enabled_modules]
77 77 if @project.save
78 78 @project.set_allowed_parent!(params[:project]['parent_id']) if params[:project].has_key?('parent_id')
79 79 # Add current user as a project member if he is not admin
80 80 unless User.current.admin?
81 81 r = Role.givable.find_by_id(Setting.new_project_user_role_id.to_i) || Role.givable.first
82 82 m = Member.new(:user => User.current, :roles => [r])
83 83 @project.members << m
84 84 end
85 85 flash[:notice] = l(:notice_successful_create)
86 86 redirect_to :controller => 'projects', :action => 'settings', :id => @project
87 87 end
88 88 end
89 89 end
90 90
91 91 def copy
92 92 @issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
93 93 @trackers = Tracker.all
94 94 @root_projects = Project.find(:all,
95 95 :conditions => "parent_id IS NULL AND status = #{Project::STATUS_ACTIVE}",
96 96 :order => 'name')
97 97 @source_project = Project.find(params[:id])
98 98 if request.get?
99 99 @project = Project.copy_from(@source_project)
100 100 if @project
101 101 @project.identifier = Project.next_identifier if Setting.sequential_project_identifiers?
102 102 else
103 103 redirect_to :controller => 'admin', :action => 'projects'
104 104 end
105 105 else
106 106 @project = Project.new(params[:project])
107 107 @project.enabled_module_names = params[:enabled_modules]
108 108 if @project.copy(@source_project, :only => params[:only])
109 109 @project.set_allowed_parent!(params[:project]['parent_id']) if params[:project].has_key?('parent_id')
110 110 flash[:notice] = l(:notice_successful_create)
111 111 redirect_to :controller => 'admin', :action => 'projects'
112 112 end
113 113 end
114 114 rescue ActiveRecord::RecordNotFound
115 115 redirect_to :controller => 'admin', :action => 'projects'
116 116 end
117 117
118 118 # Show @project
119 119 def show
120 120 if params[:jump]
121 121 # try to redirect to the requested menu item
122 122 redirect_to_project_menu_item(@project, params[:jump]) && return
123 123 end
124 124
125 125 @users_by_role = @project.users_by_role
126 126 @subprojects = @project.children.visible
127 127 @news = @project.news.find(:all, :limit => 5, :include => [ :author, :project ], :order => "#{News.table_name}.created_on DESC")
128 128 @trackers = @project.rolled_up_trackers
129 129
130 130 cond = @project.project_condition(Setting.display_subprojects_issues?)
131 131
132 132 @open_issues_by_tracker = Issue.visible.count(:group => :tracker,
133 133 :include => [:project, :status, :tracker],
134 134 :conditions => ["(#{cond}) AND #{IssueStatus.table_name}.is_closed=?", false])
135 135 @total_issues_by_tracker = Issue.visible.count(:group => :tracker,
136 136 :include => [:project, :status, :tracker],
137 137 :conditions => cond)
138 138
139 139 TimeEntry.visible_by(User.current) do
140 140 @total_hours = TimeEntry.sum(:hours,
141 141 :include => :project,
142 142 :conditions => cond).to_f
143 143 end
144 144 @key = User.current.rss_key
145 145 end
146 146
147 147 def settings
148 148 @issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
149 149 @issue_category ||= IssueCategory.new
150 150 @member ||= @project.members.new
151 151 @trackers = Tracker.all
152 152 @repository ||= @project.repository
153 153 @wiki ||= @project.wiki
154 154 end
155 155
156 156 # Edit @project
157 157 def edit
158 158 if request.post?
159 159 @project.attributes = params[:project]
160 160 if @project.save
161 161 @project.set_allowed_parent!(params[:project]['parent_id']) if params[:project].has_key?('parent_id')
162 162 flash[:notice] = l(:notice_successful_update)
163 163 redirect_to :action => 'settings', :id => @project
164 164 else
165 165 settings
166 166 render :action => 'settings'
167 167 end
168 168 end
169 169 end
170 170
171 171 def modules
172 172 @project.enabled_module_names = params[:enabled_modules]
173 173 redirect_to :action => 'settings', :id => @project, :tab => 'modules'
174 174 end
175 175
176 176 def archive
177 @project.archive if request.post? && @project.active?
177 if request.post?
178 unless @project.archive
179 flash[:error] = l(:error_can_not_archive_project)
180 end
181 end
178 182 redirect_to(url_for(:controller => 'admin', :action => 'projects', :status => params[:status]))
179 183 end
180 184
181 185 def unarchive
182 186 @project.unarchive if request.post? && !@project.active?
183 187 redirect_to(url_for(:controller => 'admin', :action => 'projects', :status => params[:status]))
184 188 end
185 189
186 190 # Delete @project
187 191 def destroy
188 192 @project_to_destroy = @project
189 193 if request.post? and params[:confirm]
190 194 @project_to_destroy.destroy
191 195 redirect_to :controller => 'admin', :action => 'projects'
192 196 end
193 197 # hide project in layout
194 198 @project = nil
195 199 end
196 200
197 201 # Add a new issue category to @project
198 202 def add_issue_category
199 203 @category = @project.issue_categories.build(params[:category])
200 204 if request.post?
201 205 if @category.save
202 206 respond_to do |format|
203 207 format.html do
204 208 flash[:notice] = l(:notice_successful_create)
205 209 redirect_to :action => 'settings', :tab => 'categories', :id => @project
206 210 end
207 211 format.js do
208 212 # IE doesn't support the replace_html rjs method for select box options
209 213 render(:update) {|page| page.replace "issue_category_id",
210 214 content_tag('select', '<option></option>' + options_from_collection_for_select(@project.issue_categories, 'id', 'name', @category.id), :id => 'issue_category_id', :name => 'issue[category_id]')
211 215 }
212 216 end
213 217 end
214 218 else
215 219 respond_to do |format|
216 220 format.html
217 221 format.js do
218 222 render(:update) {|page| page.alert(@category.errors.full_messages.join('\n')) }
219 223 end
220 224 end
221 225 end
222 226 end
223 227 end
224 228
225 229 # Add a new version to @project
226 230 def add_version
227 @version = @project.versions.build(params[:version])
231 @version = @project.versions.build
232 if params[:version]
233 attributes = params[:version].dup
234 attributes.delete('sharing') unless attributes.nil? || @version.allowed_sharings.include?(attributes['sharing'])
235 @version.attributes = attributes
236 end
228 237 if request.post? and @version.save
229 238 flash[:notice] = l(:notice_successful_create)
230 239 redirect_to :action => 'settings', :tab => 'versions', :id => @project
231 240 end
232 241 end
233 242
234 243 def add_file
235 244 if request.post?
236 245 container = (params[:version_id].blank? ? @project : @project.versions.find_by_id(params[:version_id]))
237 246 attachments = attach_files(container, params[:attachments])
238 247 if !attachments.empty? && Setting.notified_events.include?('file_added')
239 248 Mailer.deliver_attachments_added(attachments)
240 249 end
241 250 redirect_to :controller => 'projects', :action => 'list_files', :id => @project
242 251 return
243 252 end
244 253 @versions = @project.versions.sort
245 254 end
246 255
247 256 def save_activities
248 257 if request.post? && params[:enumerations]
249 258 Project.transaction do
250 259 params[:enumerations].each do |id, activity|
251 260 @project.update_or_create_time_entry_activity(id, activity)
252 261 end
253 262 end
254 263 end
255 264
256 265 redirect_to :controller => 'projects', :action => 'settings', :tab => 'activities', :id => @project
257 266 end
258 267
259 268 def reset_activities
260 269 @project.time_entry_activities.each do |time_entry_activity|
261 270 time_entry_activity.destroy(time_entry_activity.parent)
262 271 end
263 272 redirect_to :controller => 'projects', :action => 'settings', :tab => 'activities', :id => @project
264 273 end
265 274
266 275 def list_files
267 276 sort_init 'filename', 'asc'
268 277 sort_update 'filename' => "#{Attachment.table_name}.filename",
269 278 'created_on' => "#{Attachment.table_name}.created_on",
270 279 'size' => "#{Attachment.table_name}.filesize",
271 280 'downloads' => "#{Attachment.table_name}.downloads"
272 281
273 282 @containers = [ Project.find(@project.id, :include => :attachments, :order => sort_clause)]
274 283 @containers += @project.versions.find(:all, :include => :attachments, :order => sort_clause).sort.reverse
275 284 render :layout => !request.xhr?
276 285 end
277 286
278 287 # Show changelog for @project
279 288 def changelog
280 289 @trackers = @project.trackers.find(:all, :conditions => ["is_in_chlog=?", true], :order => 'position')
281 retrieve_selected_tracker_ids(@trackers)
282 @versions = @project.versions.sort
290 retrieve_selected_tracker_ids(@trackers)
291 @with_subprojects = params[:with_subprojects].nil? ? Setting.display_subprojects_issues? : (params[:with_subprojects] == '1')
292 project_ids = @with_subprojects ? @project.self_and_descendants.collect(&:id) : [@project.id]
293
294 @versions = @project.shared_versions.sort
295
296 @issues_by_version = {}
297 unless @selected_tracker_ids.empty?
298 @versions.each do |version|
299 conditions = {:tracker_id => @selected_tracker_ids, "#{IssueStatus.table_name}.is_closed" => true}
300 if !@project.versions.include?(version)
301 conditions.merge!(:project_id => project_ids)
302 end
303 issues = version.fixed_issues.visible.find(:all,
304 :include => [:status, :tracker, :priority],
305 :conditions => conditions,
306 :order => "#{Tracker.table_name}.position, #{Issue.table_name}.id")
307 @issues_by_version[version] = issues
308 end
309 end
310 @versions.reject! {|version| !project_ids.include?(version.project_id) && @issues_by_version[version].empty?}
283 311 end
284 312
285 313 def roadmap
286 @trackers = @project.trackers.find(:all, :conditions => ["is_in_roadmap=?", true])
314 @trackers = @project.trackers.find(:all, :conditions => ["is_in_roadmap=?", true], :order => 'position')
287 315 retrieve_selected_tracker_ids(@trackers)
288 @versions = @project.versions.sort
289 @versions = @versions.select {|v| !v.completed? } unless params[:completed]
316 @with_subprojects = params[:with_subprojects].nil? ? Setting.display_subprojects_issues? : (params[:with_subprojects] == '1')
317 project_ids = @with_subprojects ? @project.self_and_descendants.collect(&:id) : [@project.id]
318
319 @versions = @project.shared_versions.sort
320 @versions.reject! {|version| version.closed? || version.completed? } unless params[:completed]
321
322 @issues_by_version = {}
323 unless @selected_tracker_ids.empty?
324 @versions.each do |version|
325 conditions = {:tracker_id => @selected_tracker_ids}
326 if !@project.versions.include?(version)
327 conditions.merge!(:project_id => project_ids)
328 end
329 issues = version.fixed_issues.visible.find(:all,
330 :include => [:status, :tracker, :priority],
331 :conditions => conditions,
332 :order => "#{Tracker.table_name}.position, #{Issue.table_name}.id")
333 @issues_by_version[version] = issues
334 end
335 end
336 @versions.reject! {|version| !project_ids.include?(version.project_id) && @issues_by_version[version].empty?}
290 337 end
291 338
292 339 def activity
293 340 @days = Setting.activity_days_default.to_i
294 341
295 342 if params[:from]
296 343 begin; @date_to = params[:from].to_date + 1; rescue; end
297 344 end
298 345
299 346 @date_to ||= Date.today + 1
300 347 @date_from = @date_to - @days
301 348 @with_subprojects = params[:with_subprojects].nil? ? Setting.display_subprojects_issues? : (params[:with_subprojects] == '1')
302 349 @author = (params[:user_id].blank? ? nil : User.active.find(params[:user_id]))
303 350
304 351 @activity = Redmine::Activity::Fetcher.new(User.current, :project => @project,
305 352 :with_subprojects => @with_subprojects,
306 353 :author => @author)
307 354 @activity.scope_select {|t| !params["show_#{t}"].nil?}
308 355 @activity.scope = (@author.nil? ? :default : :all) if @activity.scope.empty?
309 356
310 357 events = @activity.events(@date_from, @date_to)
311 358
312 359 if events.empty? || stale?(:etag => [events.first, User.current])
313 360 respond_to do |format|
314 361 format.html {
315 362 @events_by_day = events.group_by(&:event_date)
316 363 render :layout => false if request.xhr?
317 364 }
318 365 format.atom {
319 366 title = l(:label_activity)
320 367 if @author
321 368 title = @author.name
322 369 elsif @activity.scope.size == 1
323 370 title = l("label_#{@activity.scope.first.singularize}_plural")
324 371 end
325 372 render_feed(events, :title => "#{@project || Setting.app_title}: #{title}")
326 373 }
327 374 end
328 375 end
329 376
330 377 rescue ActiveRecord::RecordNotFound
331 378 render_404
332 379 end
333 380
334 381 private
335 382 # Find project of id params[:id]
336 383 # if not found, redirect to project list
337 384 # Used as a before_filter
338 385 def find_project
339 386 @project = Project.find(params[:id])
340 387 rescue ActiveRecord::RecordNotFound
341 388 render_404
342 389 end
343 390
344 391 def find_optional_project
345 392 return true unless params[:id]
346 393 @project = Project.find(params[:id])
347 394 authorize
348 395 rescue ActiveRecord::RecordNotFound
349 396 render_404
350 397 end
351 398
352 399 def retrieve_selected_tracker_ids(selectable_trackers)
353 400 if ids = params[:tracker_ids]
354 401 @selected_tracker_ids = (ids.is_a? Array) ? ids.collect { |id| id.to_i.to_s } : ids.split('/').collect { |id| id.to_i.to_s }
355 402 else
356 403 @selected_tracker_ids = selectable_trackers.collect {|t| t.id.to_s }
357 404 end
358 405 end
359 406 end
@@ -1,71 +1,76
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 VersionsController < ApplicationController
19 19 menu_item :roadmap
20 20 before_filter :find_version, :except => :close_completed
21 21 before_filter :find_project, :only => :close_completed
22 22 before_filter :authorize
23 23
24 24 helper :custom_fields
25 helper :projects
25 26
26 27 def show
27 28 end
28 29
29 30 def edit
30 if request.post? and @version.update_attributes(params[:version])
31 flash[:notice] = l(:notice_successful_update)
32 redirect_to :controller => 'projects', :action => 'settings', :tab => 'versions', :id => @project
31 if request.post? && params[:version]
32 attributes = params[:version].dup
33 attributes.delete('sharing') unless @version.allowed_sharings.include?(attributes['sharing'])
34 if @version.update_attributes(attributes)
35 flash[:notice] = l(:notice_successful_update)
36 redirect_to :controller => 'projects', :action => 'settings', :tab => 'versions', :id => @project
37 end
33 38 end
34 39 end
35 40
36 41 def close_completed
37 42 if request.post?
38 43 @project.close_completed_versions
39 44 end
40 45 redirect_to :controller => 'projects', :action => 'settings', :tab => 'versions', :id => @project
41 46 end
42 47
43 48 def destroy
44 49 @version.destroy
45 50 redirect_to :controller => 'projects', :action => 'settings', :tab => 'versions', :id => @project
46 51 rescue
47 52 flash[:error] = l(:notice_unable_delete_version)
48 53 redirect_to :controller => 'projects', :action => 'settings', :tab => 'versions', :id => @project
49 54 end
50 55
51 56 def status_by
52 57 respond_to do |format|
53 58 format.html { render :action => 'show' }
54 59 format.js { render(:update) {|page| page.replace_html 'status_by', render_issue_status_by(@version, params[:status_by])} }
55 60 end
56 61 end
57 62
58 63 private
59 64 def find_version
60 65 @version = Version.find(params[:id])
61 66 @project = @version.project
62 67 rescue ActiveRecord::RecordNotFound
63 68 render_404
64 69 end
65 70
66 71 def find_project
67 72 @project = Project.find(params[:project_id])
68 73 rescue ActiveRecord::RecordNotFound
69 74 render_404
70 75 end
71 76 end
@@ -1,700 +1,708
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 'coderay'
19 19 require 'coderay/helpers/file_type'
20 20 require 'forwardable'
21 21 require 'cgi'
22 22
23 23 module ApplicationHelper
24 24 include Redmine::WikiFormatting::Macros::Definitions
25 25 include Redmine::I18n
26 26 include GravatarHelper::PublicMethods
27 27
28 28 extend Forwardable
29 29 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
30 30
31 31 # Return true if user is authorized for controller/action, otherwise false
32 32 def authorize_for(controller, action)
33 33 User.current.allowed_to?({:controller => controller, :action => action}, @project)
34 34 end
35 35
36 36 # Display a link if user is authorized
37 37 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
38 38 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
39 39 end
40 40
41 41 # Display a link to remote if user is authorized
42 42 def link_to_remote_if_authorized(name, options = {}, html_options = nil)
43 43 url = options[:url] || {}
44 44 link_to_remote(name, options, html_options) if authorize_for(url[:controller] || params[:controller], url[:action])
45 45 end
46 46
47 47 # Displays a link to user's account page if active
48 48 def link_to_user(user, options={})
49 49 if user.is_a?(User)
50 50 name = h(user.name(options[:format]))
51 51 if user.active?
52 52 link_to name, :controller => 'users', :action => 'show', :id => user
53 53 else
54 54 name
55 55 end
56 56 else
57 57 h(user.to_s)
58 58 end
59 59 end
60 60
61 61 # Displays a link to +issue+ with its subject.
62 62 # Examples:
63 63 #
64 64 # link_to_issue(issue) # => Defect #6: This is the subject
65 65 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
66 66 # link_to_issue(issue, :subject => false) # => Defect #6
67 67 #
68 68 def link_to_issue(issue, options={})
69 69 title = nil
70 70 subject = nil
71 71 if options[:subject] == false
72 72 title = truncate(issue.subject, :length => 60)
73 73 else
74 74 subject = issue.subject
75 75 if options[:truncate]
76 76 subject = truncate(subject, :length => options[:truncate])
77 77 end
78 78 end
79 79 s = link_to "#{issue.tracker} ##{issue.id}", {:controller => "issues", :action => "show", :id => issue},
80 80 :class => issue.css_classes,
81 81 :title => title
82 82 s << ": #{h subject}" if subject
83 83 s
84 84 end
85 85
86 86 # Generates a link to an attachment.
87 87 # Options:
88 88 # * :text - Link text (default to attachment filename)
89 89 # * :download - Force download (default: false)
90 90 def link_to_attachment(attachment, options={})
91 91 text = options.delete(:text) || attachment.filename
92 92 action = options.delete(:download) ? 'download' : 'show'
93 93
94 94 link_to(h(text), {:controller => 'attachments', :action => action, :id => attachment, :filename => attachment.filename }, options)
95 95 end
96 96
97 97 def toggle_link(name, id, options={})
98 98 onclick = "Element.toggle('#{id}'); "
99 99 onclick << (options[:focus] ? "Form.Element.focus('#{options[:focus]}'); " : "this.blur(); ")
100 100 onclick << "return false;"
101 101 link_to(name, "#", :onclick => onclick)
102 102 end
103 103
104 104 def image_to_function(name, function, html_options = {})
105 105 html_options.symbolize_keys!
106 106 tag(:input, html_options.merge({
107 107 :type => "image", :src => image_path(name),
108 108 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
109 109 }))
110 110 end
111 111
112 112 def prompt_to_remote(name, text, param, url, html_options = {})
113 113 html_options[:onclick] = "promptToRemote('#{text}', '#{param}', '#{url_for(url)}'); return false;"
114 114 link_to name, {}, html_options
115 115 end
116 116
117 117 def format_activity_title(text)
118 118 h(truncate_single_line(text, :length => 100))
119 119 end
120 120
121 121 def format_activity_day(date)
122 122 date == Date.today ? l(:label_today).titleize : format_date(date)
123 123 end
124 124
125 125 def format_activity_description(text)
126 126 h(truncate(text.to_s, :length => 120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')).gsub(/[\r\n]+/, "<br />")
127 127 end
128 128
129 def format_version_name(version)
130 if version.project == @project
131 h(version)
132 else
133 h("#{version.project} - #{version}")
134 end
135 end
136
129 137 def due_date_distance_in_words(date)
130 138 if date
131 139 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
132 140 end
133 141 end
134 142
135 143 def render_page_hierarchy(pages, node=nil)
136 144 content = ''
137 145 if pages[node]
138 146 content << "<ul class=\"pages-hierarchy\">\n"
139 147 pages[node].each do |page|
140 148 content << "<li>"
141 149 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'index', :id => page.project, :page => page.title},
142 150 :title => (page.respond_to?(:updated_on) ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
143 151 content << "\n" + render_page_hierarchy(pages, page.id) if pages[page.id]
144 152 content << "</li>\n"
145 153 end
146 154 content << "</ul>\n"
147 155 end
148 156 content
149 157 end
150 158
151 159 # Renders flash messages
152 160 def render_flash_messages
153 161 s = ''
154 162 flash.each do |k,v|
155 163 s << content_tag('div', v, :class => "flash #{k}")
156 164 end
157 165 s
158 166 end
159 167
160 168 # Renders tabs and their content
161 169 def render_tabs(tabs)
162 170 if tabs.any?
163 171 render :partial => 'common/tabs', :locals => {:tabs => tabs}
164 172 else
165 173 content_tag 'p', l(:label_no_data), :class => "nodata"
166 174 end
167 175 end
168 176
169 177 # Renders the project quick-jump box
170 178 def render_project_jump_box
171 179 # Retrieve them now to avoid a COUNT query
172 180 projects = User.current.projects.all
173 181 if projects.any?
174 182 s = '<select onchange="if (this.value != \'\') { window.location = this.value; }">' +
175 183 "<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
176 184 '<option value="" disabled="disabled">---</option>'
177 185 s << project_tree_options_for_select(projects, :selected => @project) do |p|
178 186 { :value => url_for(:controller => 'projects', :action => 'show', :id => p, :jump => current_menu_item) }
179 187 end
180 188 s << '</select>'
181 189 s
182 190 end
183 191 end
184 192
185 193 def project_tree_options_for_select(projects, options = {})
186 194 s = ''
187 195 project_tree(projects) do |project, level|
188 196 name_prefix = (level > 0 ? ('&nbsp;' * 2 * level + '&#187; ') : '')
189 197 tag_options = {:value => project.id, :selected => ((project == options[:selected]) ? 'selected' : nil)}
190 198 tag_options.merge!(yield(project)) if block_given?
191 199 s << content_tag('option', name_prefix + h(project), tag_options)
192 200 end
193 201 s
194 202 end
195 203
196 204 # Yields the given block for each project with its level in the tree
197 205 def project_tree(projects, &block)
198 206 ancestors = []
199 207 projects.sort_by(&:lft).each do |project|
200 208 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
201 209 ancestors.pop
202 210 end
203 211 yield project, ancestors.size
204 212 ancestors << project
205 213 end
206 214 end
207 215
208 216 def project_nested_ul(projects, &block)
209 217 s = ''
210 218 if projects.any?
211 219 ancestors = []
212 220 projects.sort_by(&:lft).each do |project|
213 221 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
214 222 s << "<ul>\n"
215 223 else
216 224 ancestors.pop
217 225 s << "</li>"
218 226 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
219 227 ancestors.pop
220 228 s << "</ul></li>\n"
221 229 end
222 230 end
223 231 s << "<li>"
224 232 s << yield(project).to_s
225 233 ancestors << project
226 234 end
227 235 s << ("</li></ul>\n" * ancestors.size)
228 236 end
229 237 s
230 238 end
231 239
232 240 def principals_check_box_tags(name, principals)
233 241 s = ''
234 242 principals.sort.each do |principal|
235 243 s << "<label>#{ check_box_tag name, principal.id, false } #{h principal}</label>\n"
236 244 end
237 245 s
238 246 end
239 247
240 248 # Truncates and returns the string as a single line
241 249 def truncate_single_line(string, *args)
242 250 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
243 251 end
244 252
245 253 def html_hours(text)
246 254 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>')
247 255 end
248 256
249 257 def authoring(created, author, options={})
250 258 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created))
251 259 end
252 260
253 261 def time_tag(time)
254 262 text = distance_of_time_in_words(Time.now, time)
255 263 if @project
256 264 link_to(text, {:controller => 'projects', :action => 'activity', :id => @project, :from => time.to_date}, :title => format_time(time))
257 265 else
258 266 content_tag('acronym', text, :title => format_time(time))
259 267 end
260 268 end
261 269
262 270 def syntax_highlight(name, content)
263 271 type = CodeRay::FileType[name]
264 272 type ? CodeRay.scan(content, type).html : h(content)
265 273 end
266 274
267 275 def to_path_param(path)
268 276 path.to_s.split(%r{[/\\]}).select {|p| !p.blank?}
269 277 end
270 278
271 279 def pagination_links_full(paginator, count=nil, options={})
272 280 page_param = options.delete(:page_param) || :page
273 281 url_param = params.dup
274 282 # don't reuse query params if filters are present
275 283 url_param.merge!(:fields => nil, :values => nil, :operators => nil) if url_param.delete(:set_filter)
276 284
277 285 html = ''
278 286 if paginator.current.previous
279 287 html << link_to_remote_content_update('&#171; ' + l(:label_previous), url_param.merge(page_param => paginator.current.previous)) + ' '
280 288 end
281 289
282 290 html << (pagination_links_each(paginator, options) do |n|
283 291 link_to_remote_content_update(n.to_s, url_param.merge(page_param => n))
284 292 end || '')
285 293
286 294 if paginator.current.next
287 295 html << ' ' + link_to_remote_content_update((l(:label_next) + ' &#187;'), url_param.merge(page_param => paginator.current.next))
288 296 end
289 297
290 298 unless count.nil?
291 299 html << [
292 300 " (#{paginator.current.first_item}-#{paginator.current.last_item}/#{count})",
293 301 per_page_links(paginator.items_per_page)
294 302 ].compact.join(' | ')
295 303 end
296 304
297 305 html
298 306 end
299 307
300 308 def per_page_links(selected=nil)
301 309 url_param = params.dup
302 310 url_param.clear if url_param.has_key?(:set_filter)
303 311
304 312 links = Setting.per_page_options_array.collect do |n|
305 313 n == selected ? n : link_to_remote(n, {:update => "content",
306 314 :url => params.dup.merge(:per_page => n),
307 315 :method => :get},
308 316 {:href => url_for(url_param.merge(:per_page => n))})
309 317 end
310 318 links.size > 1 ? l(:label_display_per_page, links.join(', ')) : nil
311 319 end
312 320
313 321 def reorder_links(name, url)
314 322 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)), url.merge({"#{name}[move_to]" => 'highest'}), :method => :post, :title => l(:label_sort_highest)) +
315 323 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)), url.merge({"#{name}[move_to]" => 'higher'}), :method => :post, :title => l(:label_sort_higher)) +
316 324 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)), url.merge({"#{name}[move_to]" => 'lower'}), :method => :post, :title => l(:label_sort_lower)) +
317 325 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)), url.merge({"#{name}[move_to]" => 'lowest'}), :method => :post, :title => l(:label_sort_lowest))
318 326 end
319 327
320 328 def breadcrumb(*args)
321 329 elements = args.flatten
322 330 elements.any? ? content_tag('p', args.join(' &#187; ') + ' &#187; ', :class => 'breadcrumb') : nil
323 331 end
324 332
325 333 def other_formats_links(&block)
326 334 concat('<p class="other-formats">' + l(:label_export_to))
327 335 yield Redmine::Views::OtherFormatsBuilder.new(self)
328 336 concat('</p>')
329 337 end
330 338
331 339 def page_header_title
332 340 if @project.nil? || @project.new_record?
333 341 h(Setting.app_title)
334 342 else
335 343 b = []
336 344 ancestors = (@project.root? ? [] : @project.ancestors.visible)
337 345 if ancestors.any?
338 346 root = ancestors.shift
339 347 b << link_to(h(root), {:controller => 'projects', :action => 'show', :id => root, :jump => current_menu_item}, :class => 'root')
340 348 if ancestors.size > 2
341 349 b << '&#8230;'
342 350 ancestors = ancestors[-2, 2]
343 351 end
344 352 b += ancestors.collect {|p| link_to(h(p), {:controller => 'projects', :action => 'show', :id => p, :jump => current_menu_item}, :class => 'ancestor') }
345 353 end
346 354 b << h(@project)
347 355 b.join(' &#187; ')
348 356 end
349 357 end
350 358
351 359 def html_title(*args)
352 360 if args.empty?
353 361 title = []
354 362 title << @project.name if @project
355 363 title += @html_title if @html_title
356 364 title << Setting.app_title
357 365 title.select {|t| !t.blank? }.join(' - ')
358 366 else
359 367 @html_title ||= []
360 368 @html_title += args
361 369 end
362 370 end
363 371
364 372 def accesskey(s)
365 373 Redmine::AccessKeys.key_for s
366 374 end
367 375
368 376 # Formats text according to system settings.
369 377 # 2 ways to call this method:
370 378 # * with a String: textilizable(text, options)
371 379 # * with an object and one of its attribute: textilizable(issue, :description, options)
372 380 def textilizable(*args)
373 381 options = args.last.is_a?(Hash) ? args.pop : {}
374 382 case args.size
375 383 when 1
376 384 obj = options[:object]
377 385 text = args.shift
378 386 when 2
379 387 obj = args.shift
380 388 text = obj.send(args.shift).to_s
381 389 else
382 390 raise ArgumentError, 'invalid arguments to textilizable'
383 391 end
384 392 return '' if text.blank?
385 393
386 394 only_path = options.delete(:only_path) == false ? false : true
387 395
388 396 # when using an image link, try to use an attachment, if possible
389 397 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
390 398
391 399 if attachments
392 400 attachments = attachments.sort_by(&:created_on).reverse
393 401 text = text.gsub(/!((\<|\=|\>)?(\([^\)]+\))?(\[[^\]]+\])?(\{[^\}]+\})?)(\S+\.(bmp|gif|jpg|jpeg|png))!/i) do |m|
394 402 style = $1
395 403 filename = $6.downcase
396 404 # search for the picture in attachments
397 405 if found = attachments.detect { |att| att.filename.downcase == filename }
398 406 image_url = url_for :only_path => only_path, :controller => 'attachments', :action => 'download', :id => found
399 407 desc = found.description.to_s.gsub(/^([^\(\)]*).*$/, "\\1")
400 408 alt = desc.blank? ? nil : "(#{desc})"
401 409 "!#{style}#{image_url}#{alt}!"
402 410 else
403 411 m
404 412 end
405 413 end
406 414 end
407 415
408 416 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text) { |macro, args| exec_macro(macro, obj, args) }
409 417
410 418 # different methods for formatting wiki links
411 419 case options[:wiki_links]
412 420 when :local
413 421 # used for local links to html files
414 422 format_wiki_link = Proc.new {|project, title, anchor| "#{title}.html" }
415 423 when :anchor
416 424 # used for single-file wiki export
417 425 format_wiki_link = Proc.new {|project, title, anchor| "##{title}" }
418 426 else
419 427 format_wiki_link = Proc.new {|project, title, anchor| url_for(:only_path => only_path, :controller => 'wiki', :action => 'index', :id => project, :page => title, :anchor => anchor) }
420 428 end
421 429
422 430 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
423 431
424 432 # Wiki links
425 433 #
426 434 # Examples:
427 435 # [[mypage]]
428 436 # [[mypage|mytext]]
429 437 # wiki links can refer other project wikis, using project name or identifier:
430 438 # [[project:]] -> wiki starting page
431 439 # [[project:|mytext]]
432 440 # [[project:mypage]]
433 441 # [[project:mypage|mytext]]
434 442 text = text.gsub(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
435 443 link_project = project
436 444 esc, all, page, title = $1, $2, $3, $5
437 445 if esc.nil?
438 446 if page =~ /^([^\:]+)\:(.*)$/
439 447 link_project = Project.find_by_name($1) || Project.find_by_identifier($1)
440 448 page = $2
441 449 title ||= $1 if page.blank?
442 450 end
443 451
444 452 if link_project && link_project.wiki
445 453 # extract anchor
446 454 anchor = nil
447 455 if page =~ /^(.+?)\#(.+)$/
448 456 page, anchor = $1, $2
449 457 end
450 458 # check if page exists
451 459 wiki_page = link_project.wiki.find_page(page)
452 460 link_to((title || page), format_wiki_link.call(link_project, Wiki.titleize(page), anchor),
453 461 :class => ('wiki-page' + (wiki_page ? '' : ' new')))
454 462 else
455 463 # project or wiki doesn't exist
456 464 all
457 465 end
458 466 else
459 467 all
460 468 end
461 469 end
462 470
463 471 # Redmine links
464 472 #
465 473 # Examples:
466 474 # Issues:
467 475 # #52 -> Link to issue #52
468 476 # Changesets:
469 477 # r52 -> Link to revision 52
470 478 # commit:a85130f -> Link to scmid starting with a85130f
471 479 # Documents:
472 480 # document#17 -> Link to document with id 17
473 481 # document:Greetings -> Link to the document with title "Greetings"
474 482 # document:"Some document" -> Link to the document with title "Some document"
475 483 # Versions:
476 484 # version#3 -> Link to version with id 3
477 485 # version:1.0.0 -> Link to version named "1.0.0"
478 486 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
479 487 # Attachments:
480 488 # attachment:file.zip -> Link to the attachment of the current object named file.zip
481 489 # Source files:
482 490 # source:some/file -> Link to the file located at /some/file in the project's repository
483 491 # source:some/file@52 -> Link to the file's revision 52
484 492 # source:some/file#L120 -> Link to line 120 of the file
485 493 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
486 494 # export:some/file -> Force the download of the file
487 495 # Forum messages:
488 496 # message#1218 -> Link to message with id 1218
489 497 text = text.gsub(%r{([\s\(,\-\>]|^)(!)?(attachment|document|version|commit|source|export|message)?((#|r)(\d+)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]]\W)|,|\s|<|$)}) do |m|
490 498 leading, esc, prefix, sep, oid = $1, $2, $3, $5 || $7, $6 || $8
491 499 link = nil
492 500 if esc.nil?
493 501 if prefix.nil? && sep == 'r'
494 502 if project && (changeset = project.changesets.find_by_revision(oid))
495 503 link = link_to("r#{oid}", {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :rev => oid},
496 504 :class => 'changeset',
497 505 :title => truncate_single_line(changeset.comments, :length => 100))
498 506 end
499 507 elsif sep == '#'
500 508 oid = oid.to_i
501 509 case prefix
502 510 when nil
503 511 if issue = Issue.visible.find_by_id(oid, :include => :status)
504 512 link = link_to("##{oid}", {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid},
505 513 :class => issue.css_classes,
506 514 :title => "#{truncate(issue.subject, :length => 100)} (#{issue.status.name})")
507 515 end
508 516 when 'document'
509 517 if document = Document.find_by_id(oid, :include => [:project], :conditions => Project.visible_by(User.current))
510 518 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
511 519 :class => 'document'
512 520 end
513 521 when 'version'
514 522 if version = Version.find_by_id(oid, :include => [:project], :conditions => Project.visible_by(User.current))
515 523 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
516 524 :class => 'version'
517 525 end
518 526 when 'message'
519 527 if message = Message.find_by_id(oid, :include => [:parent, {:board => :project}], :conditions => Project.visible_by(User.current))
520 528 link = link_to h(truncate(message.subject, :length => 60)), {:only_path => only_path,
521 529 :controller => 'messages',
522 530 :action => 'show',
523 531 :board_id => message.board,
524 532 :id => message.root,
525 533 :anchor => (message.parent ? "message-#{message.id}" : nil)},
526 534 :class => 'message'
527 535 end
528 536 end
529 537 elsif sep == ':'
530 538 # removes the double quotes if any
531 539 name = oid.gsub(%r{^"(.*)"$}, "\\1")
532 540 case prefix
533 541 when 'document'
534 542 if project && document = project.documents.find_by_title(name)
535 543 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
536 544 :class => 'document'
537 545 end
538 546 when 'version'
539 547 if project && version = project.versions.find_by_name(name)
540 548 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
541 549 :class => 'version'
542 550 end
543 551 when 'commit'
544 552 if project && (changeset = project.changesets.find(:first, :conditions => ["scmid LIKE ?", "#{name}%"]))
545 553 link = link_to h("#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :rev => changeset.revision},
546 554 :class => 'changeset',
547 555 :title => truncate_single_line(changeset.comments, :length => 100)
548 556 end
549 557 when 'source', 'export'
550 558 if project && project.repository
551 559 name =~ %r{^[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?$}
552 560 path, rev, anchor = $1, $3, $5
553 561 link = link_to h("#{prefix}:#{name}"), {:controller => 'repositories', :action => 'entry', :id => project,
554 562 :path => to_path_param(path),
555 563 :rev => rev,
556 564 :anchor => anchor,
557 565 :format => (prefix == 'export' ? 'raw' : nil)},
558 566 :class => (prefix == 'export' ? 'source download' : 'source')
559 567 end
560 568 when 'attachment'
561 569 if attachments && attachment = attachments.detect {|a| a.filename == name }
562 570 link = link_to h(attachment.filename), {:only_path => only_path, :controller => 'attachments', :action => 'download', :id => attachment},
563 571 :class => 'attachment'
564 572 end
565 573 end
566 574 end
567 575 end
568 576 leading + (link || "#{prefix}#{sep}#{oid}")
569 577 end
570 578
571 579 text
572 580 end
573 581
574 582 # Same as Rails' simple_format helper without using paragraphs
575 583 def simple_format_without_paragraph(text)
576 584 text.to_s.
577 585 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
578 586 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
579 587 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />') # 1 newline -> br
580 588 end
581 589
582 590 def lang_options_for_select(blank=true)
583 591 (blank ? [["(auto)", ""]] : []) +
584 592 valid_languages.collect{|lang| [ ll(lang.to_s, :general_lang_name), lang.to_s]}.sort{|x,y| x.last <=> y.last }
585 593 end
586 594
587 595 def label_tag_for(name, option_tags = nil, options = {})
588 596 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
589 597 content_tag("label", label_text)
590 598 end
591 599
592 600 def labelled_tabular_form_for(name, object, options, &proc)
593 601 options[:html] ||= {}
594 602 options[:html][:class] = 'tabular' unless options[:html].has_key?(:class)
595 603 form_for(name, object, options.merge({ :builder => TabularFormBuilder, :lang => current_language}), &proc)
596 604 end
597 605
598 606 def back_url_hidden_field_tag
599 607 back_url = params[:back_url] || request.env['HTTP_REFERER']
600 608 back_url = CGI.unescape(back_url.to_s)
601 609 hidden_field_tag('back_url', CGI.escape(back_url)) unless back_url.blank?
602 610 end
603 611
604 612 def check_all_links(form_name)
605 613 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
606 614 " | " +
607 615 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
608 616 end
609 617
610 618 def progress_bar(pcts, options={})
611 619 pcts = [pcts, pcts] unless pcts.is_a?(Array)
612 620 pcts = pcts.collect(&:round)
613 621 pcts[1] = pcts[1] - pcts[0]
614 622 pcts << (100 - pcts[1] - pcts[0])
615 623 width = options[:width] || '100px;'
616 624 legend = options[:legend] || ''
617 625 content_tag('table',
618 626 content_tag('tr',
619 627 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : '') +
620 628 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : '') +
621 629 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : '')
622 630 ), :class => 'progress', :style => "width: #{width};") +
623 631 content_tag('p', legend, :class => 'pourcent')
624 632 end
625 633
626 634 def context_menu_link(name, url, options={})
627 635 options[:class] ||= ''
628 636 if options.delete(:selected)
629 637 options[:class] << ' icon-checked disabled'
630 638 options[:disabled] = true
631 639 end
632 640 if options.delete(:disabled)
633 641 options.delete(:method)
634 642 options.delete(:confirm)
635 643 options.delete(:onclick)
636 644 options[:class] << ' disabled'
637 645 url = '#'
638 646 end
639 647 link_to name, url, options
640 648 end
641 649
642 650 def calendar_for(field_id)
643 651 include_calendar_headers_tags
644 652 image_tag("calendar.png", {:id => "#{field_id}_trigger",:class => "calendar-trigger"}) +
645 653 javascript_tag("Calendar.setup({inputField : '#{field_id}', ifFormat : '%Y-%m-%d', button : '#{field_id}_trigger' });")
646 654 end
647 655
648 656 def include_calendar_headers_tags
649 657 unless @calendar_headers_tags_included
650 658 @calendar_headers_tags_included = true
651 659 content_for :header_tags do
652 660 javascript_include_tag('calendar/calendar') +
653 661 javascript_include_tag("calendar/lang/calendar-#{current_language.to_s.downcase}.js") +
654 662 javascript_include_tag('calendar/calendar-setup') +
655 663 stylesheet_link_tag('calendar')
656 664 end
657 665 end
658 666 end
659 667
660 668 def content_for(name, content = nil, &block)
661 669 @has_content ||= {}
662 670 @has_content[name] = true
663 671 super(name, content, &block)
664 672 end
665 673
666 674 def has_content?(name)
667 675 (@has_content && @has_content[name]) || false
668 676 end
669 677
670 678 # Returns the avatar image tag for the given +user+ if avatars are enabled
671 679 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
672 680 def avatar(user, options = { })
673 681 if Setting.gravatar_enabled?
674 682 options.merge!({:ssl => Setting.protocol == 'https', :default => Setting.gravatar_default})
675 683 email = nil
676 684 if user.respond_to?(:mail)
677 685 email = user.mail
678 686 elsif user.to_s =~ %r{<(.+?)>}
679 687 email = $1
680 688 end
681 689 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
682 690 end
683 691 end
684 692
685 693 private
686 694
687 695 def wiki_helper
688 696 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
689 697 extend helper
690 698 return self
691 699 end
692 700
693 701 def link_to_remote_content_update(text, url_params)
694 702 link_to_remote(text,
695 703 {:url => url_params, :method => :get, :update => 'content', :complete => 'window.scrollTo(0,0)'},
696 704 {:href => url_for(:params => url_params)}
697 705 )
698 706 end
699 707
700 708 end
@@ -1,199 +1,199
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 module IssuesHelper
19 19 include ApplicationHelper
20 20
21 21 def render_issue_tooltip(issue)
22 22 @cached_label_start_date ||= l(:field_start_date)
23 23 @cached_label_due_date ||= l(:field_due_date)
24 24 @cached_label_assigned_to ||= l(:field_assigned_to)
25 25 @cached_label_priority ||= l(:field_priority)
26 26
27 27 link_to_issue(issue) + "<br /><br />" +
28 28 "<strong>#{@cached_label_start_date}</strong>: #{format_date(issue.start_date)}<br />" +
29 29 "<strong>#{@cached_label_due_date}</strong>: #{format_date(issue.due_date)}<br />" +
30 30 "<strong>#{@cached_label_assigned_to}</strong>: #{issue.assigned_to}<br />" +
31 31 "<strong>#{@cached_label_priority}</strong>: #{issue.priority.name}"
32 32 end
33 33
34 34 def render_custom_fields_rows(issue)
35 35 return if issue.custom_field_values.empty?
36 36 ordered_values = []
37 37 half = (issue.custom_field_values.size / 2.0).ceil
38 38 half.times do |i|
39 39 ordered_values << issue.custom_field_values[i]
40 40 ordered_values << issue.custom_field_values[i + half]
41 41 end
42 42 s = "<tr>\n"
43 43 n = 0
44 44 ordered_values.compact.each do |value|
45 45 s << "</tr>\n<tr>\n" if n > 0 && (n % 2) == 0
46 46 s << "\t<th>#{ h(value.custom_field.name) }:</th><td>#{ simple_format_without_paragraph(h(show_value(value))) }</td>\n"
47 47 n += 1
48 48 end
49 49 s << "</tr>\n"
50 50 s
51 51 end
52 52
53 53 def sidebar_queries
54 54 unless @sidebar_queries
55 55 # User can see public queries and his own queries
56 56 visible = ARCondition.new(["is_public = ? OR user_id = ?", true, (User.current.logged? ? User.current.id : 0)])
57 57 # Project specific queries and global queries
58 58 visible << (@project.nil? ? ["project_id IS NULL"] : ["project_id IS NULL OR project_id = ?", @project.id])
59 59 @sidebar_queries = Query.find(:all,
60 60 :select => 'id, name',
61 61 :order => "name ASC",
62 62 :conditions => visible.conditions)
63 63 end
64 64 @sidebar_queries
65 65 end
66 66
67 67 def show_detail(detail, no_html=false)
68 68 case detail.property
69 69 when 'attr'
70 70 label = l(("field_" + detail.prop_key.to_s.gsub(/\_id$/, "")).to_sym)
71 71 case detail.prop_key
72 72 when 'due_date', 'start_date'
73 73 value = format_date(detail.value.to_date) if detail.value
74 74 old_value = format_date(detail.old_value.to_date) if detail.old_value
75 75 when 'project_id'
76 76 p = Project.find_by_id(detail.value) and value = p.name if detail.value
77 77 p = Project.find_by_id(detail.old_value) and old_value = p.name if detail.old_value
78 78 when 'status_id'
79 79 s = IssueStatus.find_by_id(detail.value) and value = s.name if detail.value
80 80 s = IssueStatus.find_by_id(detail.old_value) and old_value = s.name if detail.old_value
81 81 when 'tracker_id'
82 82 t = Tracker.find_by_id(detail.value) and value = t.name if detail.value
83 83 t = Tracker.find_by_id(detail.old_value) and old_value = t.name if detail.old_value
84 84 when 'assigned_to_id'
85 85 u = User.find_by_id(detail.value) and value = u.name if detail.value
86 86 u = User.find_by_id(detail.old_value) and old_value = u.name if detail.old_value
87 87 when 'priority_id'
88 88 e = IssuePriority.find_by_id(detail.value) and value = e.name if detail.value
89 89 e = IssuePriority.find_by_id(detail.old_value) and old_value = e.name if detail.old_value
90 90 when 'category_id'
91 91 c = IssueCategory.find_by_id(detail.value) and value = c.name if detail.value
92 92 c = IssueCategory.find_by_id(detail.old_value) and old_value = c.name if detail.old_value
93 93 when 'fixed_version_id'
94 v = Version.find_by_id(detail.value) and value = v.name if detail.value
95 v = Version.find_by_id(detail.old_value) and old_value = v.name if detail.old_value
94 v = Version.find_by_id(detail.value) and value = format_version_name(v) if detail.value
95 v = Version.find_by_id(detail.old_value) and old_value = format_version_name(v) if detail.old_value
96 96 when 'estimated_hours'
97 97 value = "%0.02f" % detail.value.to_f unless detail.value.blank?
98 98 old_value = "%0.02f" % detail.old_value.to_f unless detail.old_value.blank?
99 99 end
100 100 when 'cf'
101 101 custom_field = CustomField.find_by_id(detail.prop_key)
102 102 if custom_field
103 103 label = custom_field.name
104 104 value = format_value(detail.value, custom_field.field_format) if detail.value
105 105 old_value = format_value(detail.old_value, custom_field.field_format) if detail.old_value
106 106 end
107 107 when 'attachment'
108 108 label = l(:label_attachment)
109 109 end
110 110 call_hook(:helper_issues_show_detail_after_setting, {:detail => detail, :label => label, :value => value, :old_value => old_value })
111 111
112 112 label ||= detail.prop_key
113 113 value ||= detail.value
114 114 old_value ||= detail.old_value
115 115
116 116 unless no_html
117 117 label = content_tag('strong', label)
118 118 old_value = content_tag("i", h(old_value)) if detail.old_value
119 119 old_value = content_tag("strike", old_value) if detail.old_value and (!detail.value or detail.value.empty?)
120 120 if detail.property == 'attachment' && !value.blank? && a = Attachment.find_by_id(detail.prop_key)
121 121 # Link to the attachment if it has not been removed
122 122 value = link_to_attachment(a)
123 123 else
124 124 value = content_tag("i", h(value)) if value
125 125 end
126 126 end
127 127
128 128 if !detail.value.blank?
129 129 case detail.property
130 130 when 'attr', 'cf'
131 131 if !detail.old_value.blank?
132 132 l(:text_journal_changed, :label => label, :old => old_value, :new => value)
133 133 else
134 134 l(:text_journal_set_to, :label => label, :value => value)
135 135 end
136 136 when 'attachment'
137 137 l(:text_journal_added, :label => label, :value => value)
138 138 end
139 139 else
140 140 l(:text_journal_deleted, :label => label, :old => old_value)
141 141 end
142 142 end
143 143
144 144 def issues_to_csv(issues, project = nil)
145 145 ic = Iconv.new(l(:general_csv_encoding), 'UTF-8')
146 146 decimal_separator = l(:general_csv_decimal_separator)
147 147 export = FCSV.generate(:col_sep => l(:general_csv_separator)) do |csv|
148 148 # csv header fields
149 149 headers = [ "#",
150 150 l(:field_status),
151 151 l(:field_project),
152 152 l(:field_tracker),
153 153 l(:field_priority),
154 154 l(:field_subject),
155 155 l(:field_assigned_to),
156 156 l(:field_category),
157 157 l(:field_fixed_version),
158 158 l(:field_author),
159 159 l(:field_start_date),
160 160 l(:field_due_date),
161 161 l(:field_done_ratio),
162 162 l(:field_estimated_hours),
163 163 l(:field_created_on),
164 164 l(:field_updated_on)
165 165 ]
166 166 # Export project custom fields if project is given
167 167 # otherwise export custom fields marked as "For all projects"
168 168 custom_fields = project.nil? ? IssueCustomField.for_all : project.all_issue_custom_fields
169 169 custom_fields.each {|f| headers << f.name}
170 170 # Description in the last column
171 171 headers << l(:field_description)
172 172 csv << headers.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
173 173 # csv lines
174 174 issues.each do |issue|
175 175 fields = [issue.id,
176 176 issue.status.name,
177 177 issue.project.name,
178 178 issue.tracker.name,
179 179 issue.priority.name,
180 180 issue.subject,
181 181 issue.assigned_to,
182 182 issue.category,
183 183 issue.fixed_version,
184 184 issue.author.name,
185 185 format_date(issue.start_date),
186 186 format_date(issue.due_date),
187 187 issue.done_ratio,
188 188 issue.estimated_hours.to_s.gsub('.', decimal_separator),
189 189 format_time(issue.created_on),
190 190 format_time(issue.updated_on)
191 191 ]
192 192 custom_fields.each {|f| fields << show_value(issue.custom_value_for(f)) }
193 193 fields << issue.description
194 194 csv << fields.collect {|c| begin; ic.iconv(c.to_s); rescue; c.to_s; end }
195 195 end
196 196 end
197 197 export
198 198 end
199 199 end
@@ -1,72 +1,95
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 module ProjectsHelper
19 19 def link_to_version(version, options = {})
20 20 return '' unless version && version.is_a?(Version)
21 link_to h(version.name), { :controller => 'versions', :action => 'show', :id => version }, options
21 link_to_if version.visible?, format_version_name(version), { :controller => 'versions', :action => 'show', :id => version }, options
22 22 end
23 23
24 24 def project_settings_tabs
25 25 tabs = [{:name => 'info', :action => :edit_project, :partial => 'projects/edit', :label => :label_information_plural},
26 26 {:name => 'modules', :action => :select_project_modules, :partial => 'projects/settings/modules', :label => :label_module_plural},
27 27 {:name => 'members', :action => :manage_members, :partial => 'projects/settings/members', :label => :label_member_plural},
28 28 {:name => 'versions', :action => :manage_versions, :partial => 'projects/settings/versions', :label => :label_version_plural},
29 29 {:name => 'categories', :action => :manage_categories, :partial => 'projects/settings/issue_categories', :label => :label_issue_category_plural},
30 30 {:name => 'wiki', :action => :manage_wiki, :partial => 'projects/settings/wiki', :label => :label_wiki},
31 31 {:name => 'repository', :action => :manage_repository, :partial => 'projects/settings/repository', :label => :label_repository},
32 32 {:name => 'boards', :action => :manage_boards, :partial => 'projects/settings/boards', :label => :label_board_plural},
33 33 {:name => 'activities', :action => :manage_project_activities, :partial => 'projects/settings/activities', :label => :enumeration_activities}
34 34 ]
35 35 tabs.select {|tab| User.current.allowed_to?(tab[:action], @project)}
36 36 end
37 37
38 38 def parent_project_select_tag(project)
39 39 options = '<option></option>' + project_tree_options_for_select(project.allowed_parents, :selected => project.parent)
40 40 content_tag('select', options, :name => 'project[parent_id]')
41 41 end
42 42
43 43 # Renders a tree of projects as a nested set of unordered lists
44 44 # The given collection may be a subset of the whole project tree
45 45 # (eg. some intermediate nodes are private and can not be seen)
46 46 def render_project_hierarchy(projects)
47 47 s = ''
48 48 if projects.any?
49 49 ancestors = []
50 50 projects.each do |project|
51 51 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
52 52 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
53 53 else
54 54 ancestors.pop
55 55 s << "</li>"
56 56 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
57 57 ancestors.pop
58 58 s << "</ul></li>\n"
59 59 end
60 60 end
61 61 classes = (ancestors.empty? ? 'root' : 'child')
62 62 s << "<li class='#{classes}'><div class='#{classes}'>" +
63 63 link_to(h(project), {:controller => 'projects', :action => 'show', :id => project}, :class => "project #{User.current.member_of?(project) ? 'my-project' : nil}")
64 64 s << "<div class='wiki description'>#{textilizable(project.short_description, :project => project)}</div>" unless project.description.blank?
65 65 s << "</div>\n"
66 66 ancestors << project
67 67 end
68 68 s << ("</li></ul>\n" * ancestors.size)
69 69 end
70 70 s
71 71 end
72
73 # Returns a set of options for a select field, grouped by project.
74 def version_options_for_select(versions, selected=nil)
75 grouped = Hash.new {|h,k| h[k] = []}
76 versions.each do |version|
77 grouped[version.project.name] << [h(version.name), version.id]
78 end
79 # Add in the selected
80 if selected && !versions.include?(selected)
81 grouped[selected.project.name] << [h(selected.name), selected.id]
82 end
83
84 if grouped.keys.size > 1
85 grouped_options_for_select(grouped, selected && selected.id)
86 else
87 options_for_select(grouped.values.first, selected && selected.id)
88 end
89 end
90
91 def format_version_sharing(sharing)
92 sharing = 'none' unless Version::VERSION_SHARINGS.include?(sharing)
93 l("label_version_sharing_#{sharing}")
94 end
72 95 end
@@ -1,374 +1,394
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_attachable :after_remove => :attachment_removed
36 36 acts_as_customizable
37 37 acts_as_watchable
38 38 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
39 39 :include => [:project, :journals],
40 40 # sort by id so that limited eager loading doesn't break with postgresql
41 41 :order_column => "#{table_name}.id"
42 42 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
43 43 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
44 44 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
45 45
46 46 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
47 47 :author_key => :author_id
48 48
49 49 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
50 50 validates_length_of :subject, :maximum => 255
51 51 validates_inclusion_of :done_ratio, :in => 0..100
52 52 validates_numericality_of :estimated_hours, :allow_nil => true
53 53
54 54 named_scope :visible, lambda {|*args| { :include => :project,
55 55 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
56 56
57 57 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
58 58
59 59 after_save :create_journal
60 60
61 61 # Returns true if usr or current user is allowed to view the issue
62 62 def visible?(usr=nil)
63 63 (usr || User.current).allowed_to?(:view_issues, self.project)
64 64 end
65 65
66 66 def after_initialize
67 67 if new_record?
68 68 # set default values for new records only
69 69 self.status ||= IssueStatus.default
70 70 self.priority ||= IssuePriority.default
71 71 end
72 72 end
73 73
74 74 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
75 75 def available_custom_fields
76 76 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
77 77 end
78 78
79 79 def copy_from(arg)
80 80 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
81 81 self.attributes = issue.attributes.dup.except("id", "created_on", "updated_on")
82 82 self.custom_values = issue.custom_values.collect {|v| v.clone}
83 83 self.status = issue.status
84 84 self
85 85 end
86 86
87 87 # Moves/copies an issue to a new project and tracker
88 88 # Returns the moved/copied issue on success, false on failure
89 89 def move_to(new_project, new_tracker = nil, options = {})
90 90 options ||= {}
91 91 issue = options[:copy] ? self.clone : self
92 92 transaction do
93 93 if new_project && issue.project_id != new_project.id
94 94 # delete issue relations
95 95 unless Setting.cross_project_issue_relations?
96 96 issue.relations_from.clear
97 97 issue.relations_to.clear
98 98 end
99 99 # issue is moved to another project
100 100 # reassign to the category with same name if any
101 101 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
102 102 issue.category = new_category
103 issue.fixed_version = nil
103 # Keep the fixed_version if it's still valid in the new_project
104 unless new_project.shared_versions.include?(issue.fixed_version)
105 issue.fixed_version = nil
106 end
104 107 issue.project = new_project
105 108 end
106 109 if new_tracker
107 110 issue.tracker = new_tracker
108 111 end
109 112 if options[:copy]
110 113 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
111 114 issue.status = if options[:attributes] && options[:attributes][:status_id]
112 115 IssueStatus.find_by_id(options[:attributes][:status_id])
113 116 else
114 117 self.status
115 118 end
116 119 end
117 120 # Allow bulk setting of attributes on the issue
118 121 if options[:attributes]
119 122 issue.attributes = options[:attributes]
120 123 end
121 124 if issue.save
122 125 unless options[:copy]
123 126 # Manually update project_id on related time entries
124 127 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
125 128 end
126 129 else
127 130 Issue.connection.rollback_db_transaction
128 131 return false
129 132 end
130 133 end
131 134 return issue
132 135 end
133 136
134 137 def priority_id=(pid)
135 138 self.priority = nil
136 139 write_attribute(:priority_id, pid)
137 140 end
138 141
139 142 def tracker_id=(tid)
140 143 self.tracker = nil
141 144 write_attribute(:tracker_id, tid)
142 145 end
143 146
144 147 def estimated_hours=(h)
145 148 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
146 149 end
147 150
148 151 def validate
149 152 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
150 153 errors.add :due_date, :not_a_date
151 154 end
152 155
153 156 if self.due_date and self.start_date and self.due_date < self.start_date
154 157 errors.add :due_date, :greater_than_start_date
155 158 end
156 159
157 160 if start_date && soonest_start && start_date < soonest_start
158 161 errors.add :start_date, :invalid
159 162 end
160 163
161 164 if fixed_version
162 165 if !assignable_versions.include?(fixed_version)
163 166 errors.add :fixed_version_id, :inclusion
164 167 elsif reopened? && fixed_version.closed?
165 168 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
166 169 end
167 170 end
168 171
169 172 # Checks that the issue can not be added/moved to a disabled tracker
170 173 if project && (tracker_id_changed? || project_id_changed?)
171 174 unless project.trackers.include?(tracker)
172 175 errors.add :tracker_id, :inclusion
173 176 end
174 177 end
175 178 end
176 179
177 180 def before_create
178 181 # default assignment based on category
179 182 if assigned_to.nil? && category && category.assigned_to
180 183 self.assigned_to = category.assigned_to
181 184 end
182 185 end
183 186
184 187 def after_save
185 188 # Reload is needed in order to get the right status
186 189 reload
187 190
188 191 # Update start/due dates of following issues
189 192 relations_from.each(&:set_issue_to_dates)
190 193
191 194 # Close duplicates if the issue was closed
192 195 if @issue_before_change && !@issue_before_change.closed? && self.closed?
193 196 duplicates.each do |duplicate|
194 197 # Reload is need in case the duplicate was updated by a previous duplicate
195 198 duplicate.reload
196 199 # Don't re-close it if it's already closed
197 200 next if duplicate.closed?
198 201 # Same user and notes
199 202 duplicate.init_journal(@current_journal.user, @current_journal.notes)
200 203 duplicate.update_attribute :status, self.status
201 204 end
202 205 end
203 206 end
204 207
205 208 def init_journal(user, notes = "")
206 209 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
207 210 @issue_before_change = self.clone
208 211 @issue_before_change.status = self.status
209 212 @custom_values_before_change = {}
210 213 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
211 214 # Make sure updated_on is updated when adding a note.
212 215 updated_on_will_change!
213 216 @current_journal
214 217 end
215 218
216 219 # Return true if the issue is closed, otherwise false
217 220 def closed?
218 221 self.status.is_closed?
219 222 end
220 223
221 224 # Return true if the issue is being reopened
222 225 def reopened?
223 226 if !new_record? && status_id_changed?
224 227 status_was = IssueStatus.find_by_id(status_id_was)
225 228 status_new = IssueStatus.find_by_id(status_id)
226 229 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
227 230 return true
228 231 end
229 232 end
230 233 false
231 234 end
232 235
233 236 # Returns true if the issue is overdue
234 237 def overdue?
235 238 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
236 239 end
237 240
238 241 # Users the issue can be assigned to
239 242 def assignable_users
240 243 project.assignable_users
241 244 end
242 245
243 246 # Versions that the issue can be assigned to
244 247 def assignable_versions
245 @assignable_versions ||= (project.versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
248 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
246 249 end
247 250
248 251 # Returns true if this issue is blocked by another issue that is still open
249 252 def blocked?
250 253 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
251 254 end
252 255
253 256 # Returns an array of status that user is able to apply
254 257 def new_statuses_allowed_to(user)
255 258 statuses = status.find_new_statuses_allowed_to(user.roles_for_project(project), tracker)
256 259 statuses << status unless statuses.empty?
257 260 statuses = statuses.uniq.sort
258 261 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
259 262 end
260 263
261 264 # Returns the mail adresses of users that should be notified
262 265 def recipients
263 266 notified = project.notified_users
264 267 # Author and assignee are always notified unless they have been locked
265 268 notified << author if author && author.active?
266 269 notified << assigned_to if assigned_to && assigned_to.active?
267 270 notified.uniq!
268 271 # Remove users that can not view the issue
269 272 notified.reject! {|user| !visible?(user)}
270 273 notified.collect(&:mail)
271 274 end
272 275
273 276 # Returns the mail adresses of watchers that should be notified
274 277 def watcher_recipients
275 278 notified = watcher_users
276 279 notified.reject! {|user| !user.active? || !visible?(user)}
277 280 notified.collect(&:mail)
278 281 end
279 282
280 283 # Returns the total number of hours spent on this issue.
281 284 #
282 285 # Example:
283 286 # spent_hours => 0
284 287 # spent_hours => 50
285 288 def spent_hours
286 289 @spent_hours ||= time_entries.sum(:hours) || 0
287 290 end
288 291
289 292 def relations
290 293 (relations_from + relations_to).sort
291 294 end
292 295
293 296 def all_dependent_issues
294 297 dependencies = []
295 298 relations_from.each do |relation|
296 299 dependencies << relation.issue_to
297 300 dependencies += relation.issue_to.all_dependent_issues
298 301 end
299 302 dependencies
300 303 end
301 304
302 305 # Returns an array of issues that duplicate this one
303 306 def duplicates
304 307 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
305 308 end
306 309
307 310 # Returns the due date or the target due date if any
308 311 # Used on gantt chart
309 312 def due_before
310 313 due_date || (fixed_version ? fixed_version.effective_date : nil)
311 314 end
312 315
313 316 # Returns the time scheduled for this issue.
314 317 #
315 318 # Example:
316 319 # Start Date: 2/26/09, End Date: 3/04/09
317 320 # duration => 6
318 321 def duration
319 322 (start_date && due_date) ? due_date - start_date : 0
320 323 end
321 324
322 325 def soonest_start
323 326 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
324 327 end
325 328
326 329 def to_s
327 330 "#{tracker} ##{id}: #{subject}"
328 331 end
329 332
330 333 # Returns a string of css classes that apply to the issue
331 334 def css_classes
332 335 s = "issue status-#{status.position} priority-#{priority.position}"
333 336 s << ' closed' if closed?
334 337 s << ' overdue' if overdue?
335 338 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
336 339 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
337 340 s
338 341 end
342
343 # Update all issues so their versions are not pointing to a
344 # fixed_version that is outside of the issue's project hierarchy.
345 #
346 # OPTIMIZE: does a full table scan of Issues with a fixed_version.
347 def self.update_fixed_versions_from_project_hierarchy_change
348 Issue.all(:conditions => ['fixed_version_id IS NOT NULL'],
349 :include => [:project, :fixed_version]
350 ).each do |issue|
351 next if issue.project.nil? || issue.fixed_version.nil?
352 unless issue.project.shared_versions.include?(issue.fixed_version)
353 issue.init_journal(User.current)
354 issue.fixed_version = nil
355 issue.save
356 end
357 end
358 end
339 359
340 360 private
341 361
342 362 # Callback on attachment deletion
343 363 def attachment_removed(obj)
344 364 journal = init_journal(User.current)
345 365 journal.details << JournalDetail.new(:property => 'attachment',
346 366 :prop_key => obj.id,
347 367 :old_value => obj.filename)
348 368 journal.save
349 369 end
350 370
351 371 # Saves the changes in a Journal
352 372 # Called after_save
353 373 def create_journal
354 374 if @current_journal
355 375 # attributes changes
356 376 (Issue.column_names - %w(id description lock_version created_on updated_on)).each {|c|
357 377 @current_journal.details << JournalDetail.new(:property => 'attr',
358 378 :prop_key => c,
359 379 :old_value => @issue_before_change.send(c),
360 380 :value => send(c)) unless send(c)==@issue_before_change.send(c)
361 381 }
362 382 # custom fields changes
363 383 custom_values.each {|c|
364 384 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
365 385 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
366 386 @current_journal.details << JournalDetail.new(:property => 'cf',
367 387 :prop_key => c.custom_field_id,
368 388 :old_value => @custom_values_before_change[c.custom_field_id],
369 389 :value => c.value)
370 390 }
371 391 @current_journal.save
372 392 end
373 393 end
374 394 end
@@ -1,608 +1,637
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 Project < ActiveRecord::Base
19 19 # Project statuses
20 20 STATUS_ACTIVE = 1
21 21 STATUS_ARCHIVED = 9
22 22
23 23 # Specific overidden Activities
24 24 has_many :time_entry_activities
25 25 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
26 26 has_many :member_principals, :class_name => 'Member',
27 27 :include => :principal,
28 28 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
29 29 has_many :users, :through => :members
30 30 has_many :principals, :through => :member_principals, :source => :principal
31 31
32 32 has_many :enabled_modules, :dependent => :delete_all
33 33 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
34 34 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
35 35 has_many :issue_changes, :through => :issues, :source => :journals
36 36 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
37 37 has_many :time_entries, :dependent => :delete_all
38 38 has_many :queries, :dependent => :delete_all
39 39 has_many :documents, :dependent => :destroy
40 40 has_many :news, :dependent => :delete_all, :include => :author
41 41 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
42 42 has_many :boards, :dependent => :destroy, :order => "position ASC"
43 43 has_one :repository, :dependent => :destroy
44 44 has_many :changesets, :through => :repository
45 45 has_one :wiki, :dependent => :destroy
46 46 # Custom field for the project issues
47 47 has_and_belongs_to_many :issue_custom_fields,
48 48 :class_name => 'IssueCustomField',
49 49 :order => "#{CustomField.table_name}.position",
50 50 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
51 51 :association_foreign_key => 'custom_field_id'
52 52
53 53 acts_as_nested_set :order => 'name', :dependent => :destroy
54 54 acts_as_attachable :view_permission => :view_files,
55 55 :delete_permission => :manage_files
56 56
57 57 acts_as_customizable
58 58 acts_as_searchable :columns => ['name', 'description'], :project_key => 'id', :permission => nil
59 59 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
60 60 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o.id}},
61 61 :author => nil
62 62
63 63 attr_protected :status, :enabled_module_names
64 64
65 65 validates_presence_of :name, :identifier
66 66 validates_uniqueness_of :name, :identifier
67 67 validates_associated :repository, :wiki
68 68 validates_length_of :name, :maximum => 30
69 69 validates_length_of :homepage, :maximum => 255
70 70 validates_length_of :identifier, :in => 1..20
71 71 # donwcase letters, digits, dashes but not digits only
72 72 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :if => Proc.new { |p| p.identifier_changed? }
73 73 # reserved words
74 74 validates_exclusion_of :identifier, :in => %w( new )
75 75
76 76 before_destroy :delete_all_members
77 77
78 78 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
79 79 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
80 80 named_scope :all_public, { :conditions => { :is_public => true } }
81 81 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
82 82
83 83 def identifier=(identifier)
84 84 super unless identifier_frozen?
85 85 end
86 86
87 87 def identifier_frozen?
88 88 errors[:identifier].nil? && !(new_record? || identifier.blank?)
89 89 end
90 90
91 91 # returns latest created projects
92 92 # non public projects will be returned only if user is a member of those
93 93 def self.latest(user=nil, count=5)
94 94 find(:all, :limit => count, :conditions => visible_by(user), :order => "created_on DESC")
95 95 end
96 96
97 97 # Returns a SQL :conditions string used to find all active projects for the specified user.
98 98 #
99 99 # Examples:
100 100 # Projects.visible_by(admin) => "projects.status = 1"
101 101 # Projects.visible_by(normal_user) => "projects.status = 1 AND projects.is_public = 1"
102 102 def self.visible_by(user=nil)
103 103 user ||= User.current
104 104 if user && user.admin?
105 105 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
106 106 elsif user && user.memberships.any?
107 107 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND (#{Project.table_name}.is_public = #{connection.quoted_true} or #{Project.table_name}.id IN (#{user.memberships.collect{|m| m.project_id}.join(',')}))"
108 108 else
109 109 return "#{Project.table_name}.status=#{Project::STATUS_ACTIVE} AND #{Project.table_name}.is_public = #{connection.quoted_true}"
110 110 end
111 111 end
112 112
113 113 def self.allowed_to_condition(user, permission, options={})
114 114 statements = []
115 115 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
116 116 if perm = Redmine::AccessControl.permission(permission)
117 117 unless perm.project_module.nil?
118 118 # If the permission belongs to a project module, make sure the module is enabled
119 119 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
120 120 end
121 121 end
122 122 if options[:project]
123 123 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
124 124 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
125 125 base_statement = "(#{project_statement}) AND (#{base_statement})"
126 126 end
127 127 if user.admin?
128 128 # no restriction
129 129 else
130 130 statements << "1=0"
131 131 if user.logged?
132 132 if Role.non_member.allowed_to?(permission) && !options[:member]
133 133 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
134 134 end
135 135 allowed_project_ids = user.memberships.select {|m| m.roles.detect {|role| role.allowed_to?(permission)}}.collect {|m| m.project_id}
136 136 statements << "#{Project.table_name}.id IN (#{allowed_project_ids.join(',')})" if allowed_project_ids.any?
137 137 else
138 138 if Role.anonymous.allowed_to?(permission) && !options[:member]
139 139 # anonymous user allowed on public project
140 140 statements << "#{Project.table_name}.is_public = #{connection.quoted_true}"
141 141 end
142 142 end
143 143 end
144 144 statements.empty? ? base_statement : "((#{base_statement}) AND (#{statements.join(' OR ')}))"
145 145 end
146 146
147 147 # Returns the Systemwide and project specific activities
148 148 def activities(include_inactive=false)
149 149 if include_inactive
150 150 return all_activities
151 151 else
152 152 return active_activities
153 153 end
154 154 end
155 155
156 156 # Will create a new Project specific Activity or update an existing one
157 157 #
158 158 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
159 159 # does not successfully save.
160 160 def update_or_create_time_entry_activity(id, activity_hash)
161 161 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
162 162 self.create_time_entry_activity_if_needed(activity_hash)
163 163 else
164 164 activity = project.time_entry_activities.find_by_id(id.to_i)
165 165 activity.update_attributes(activity_hash) if activity
166 166 end
167 167 end
168 168
169 169 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
170 170 #
171 171 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
172 172 # does not successfully save.
173 173 def create_time_entry_activity_if_needed(activity)
174 174 if activity['parent_id']
175 175
176 176 parent_activity = TimeEntryActivity.find(activity['parent_id'])
177 177 activity['name'] = parent_activity.name
178 178 activity['position'] = parent_activity.position
179 179
180 180 if Enumeration.overridding_change?(activity, parent_activity)
181 181 project_activity = self.time_entry_activities.create(activity)
182 182
183 183 if project_activity.new_record?
184 184 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
185 185 else
186 186 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
187 187 end
188 188 end
189 189 end
190 190 end
191 191
192 192 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
193 193 #
194 194 # Examples:
195 195 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
196 196 # project.project_condition(false) => "projects.id = 1"
197 197 def project_condition(with_subprojects)
198 198 cond = "#{Project.table_name}.id = #{id}"
199 199 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
200 200 cond
201 201 end
202 202
203 203 def self.find(*args)
204 204 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
205 205 project = find_by_identifier(*args)
206 206 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
207 207 project
208 208 else
209 209 super
210 210 end
211 211 end
212 212
213 213 def to_param
214 214 # id is used for projects with a numeric identifier (compatibility)
215 215 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
216 216 end
217 217
218 218 def active?
219 219 self.status == STATUS_ACTIVE
220 220 end
221 221
222 # Archives the project and its descendants recursively
222 # Archives the project and its descendants
223 223 def archive
224 # Archive subprojects if any
225 children.each do |subproject|
226 subproject.archive
224 # Check that there is no issue of a non descendant project that is assigned
225 # to one of the project or descendant versions
226 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
227 if v_ids.any? && Issue.find(:first, :include => :project,
228 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
229 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
230 return false
227 231 end
228 update_attribute :status, STATUS_ARCHIVED
232 Project.transaction do
233 archive!
234 end
235 true
229 236 end
230 237
231 238 # Unarchives the project
232 239 # All its ancestors must be active
233 240 def unarchive
234 241 return false if ancestors.detect {|a| !a.active?}
235 242 update_attribute :status, STATUS_ACTIVE
236 243 end
237 244
238 245 # Returns an array of projects the project can be moved to
239 246 # by the current user
240 247 def allowed_parents
241 248 return @allowed_parents if @allowed_parents
242 249 @allowed_parents = (Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_project, :member => true)) - self_and_descendants)
243 250 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
244 251 @allowed_parents << parent
245 252 end
246 253 @allowed_parents
247 254 end
248 255
249 256 # Sets the parent of the project with authorization check
250 257 def set_allowed_parent!(p)
251 258 unless p.nil? || p.is_a?(Project)
252 259 if p.to_s.blank?
253 260 p = nil
254 261 else
255 262 p = Project.find_by_id(p)
256 263 return false unless p
257 264 end
258 265 end
259 266 if p.nil?
260 267 if !new_record? && allowed_parents.empty?
261 268 return false
262 269 end
263 270 elsif !allowed_parents.include?(p)
264 271 return false
265 272 end
266 273 set_parent!(p)
267 274 end
268 275
269 276 # Sets the parent of the project
270 277 # Argument can be either a Project, a String, a Fixnum or nil
271 278 def set_parent!(p)
272 279 unless p.nil? || p.is_a?(Project)
273 280 if p.to_s.blank?
274 281 p = nil
275 282 else
276 283 p = Project.find_by_id(p)
277 284 return false unless p
278 285 end
279 286 end
280 287 if p == parent && !p.nil?
281 288 # Nothing to do
282 289 true
283 290 elsif p.nil? || (p.active? && move_possible?(p))
284 291 # Insert the project so that target's children or root projects stay alphabetically sorted
285 292 sibs = (p.nil? ? self.class.roots : p.children)
286 293 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
287 294 if to_be_inserted_before
288 295 move_to_left_of(to_be_inserted_before)
289 296 elsif p.nil?
290 297 if sibs.empty?
291 298 # move_to_root adds the project in first (ie. left) position
292 299 move_to_root
293 300 else
294 301 move_to_right_of(sibs.last) unless self == sibs.last
295 302 end
296 303 else
297 304 # move_to_child_of adds the project in last (ie.right) position
298 305 move_to_child_of(p)
299 306 end
307 Issue.update_fixed_versions_from_project_hierarchy_change
300 308 true
301 309 else
302 310 # Can not move to the given target
303 311 false
304 312 end
305 313 end
306 314
307 315 # Returns an array of the trackers used by the project and its active sub projects
308 316 def rolled_up_trackers
309 317 @rolled_up_trackers ||=
310 318 Tracker.find(:all, :include => :projects,
311 319 :select => "DISTINCT #{Tracker.table_name}.*",
312 320 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
313 321 :order => "#{Tracker.table_name}.position")
314 322 end
315 323
316 324 # Closes open and locked project versions that are completed
317 325 def close_completed_versions
318 326 Version.transaction do
319 327 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
320 328 if version.completed?
321 329 version.update_attribute(:status, 'closed')
322 330 end
323 331 end
324 332 end
325 333 end
326 334
335 # Returns a scope of the Versions used by the project
336 def shared_versions
337 @shared_versions ||=
338 Version.scoped(:include => :project,
339 :conditions => "#{Project.table_name}.id = #{id}" +
340 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
341 " #{Version.table_name}.sharing = 'system'" +
342 " OR (#{Project.table_name}.lft >= #{root.lft} AND #{Project.table_name}.rgt <= #{root.rgt} AND #{Version.table_name}.sharing = 'tree')" +
343 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
344 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
345 "))")
346 end
347
327 348 # Returns a hash of project users grouped by role
328 349 def users_by_role
329 350 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
330 351 m.roles.each do |r|
331 352 h[r] ||= []
332 353 h[r] << m.user
333 354 end
334 355 h
335 356 end
336 357 end
337 358
338 359 # Deletes all project's members
339 360 def delete_all_members
340 361 me, mr = Member.table_name, MemberRole.table_name
341 362 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
342 363 Member.delete_all(['project_id = ?', id])
343 364 end
344 365
345 366 # Users issues can be assigned to
346 367 def assignable_users
347 368 members.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.user}.sort
348 369 end
349 370
350 371 # Returns the mail adresses of users that should be always notified on project events
351 372 def recipients
352 373 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user.mail}
353 374 end
354 375
355 376 # Returns the users that should be notified on project events
356 377 def notified_users
357 378 members.select {|m| m.mail_notification? || m.user.mail_notification?}.collect {|m| m.user}
358 379 end
359 380
360 381 # Returns an array of all custom fields enabled for project issues
361 382 # (explictly associated custom fields and custom fields enabled for all projects)
362 383 def all_issue_custom_fields
363 384 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
364 385 end
365 386
366 387 def project
367 388 self
368 389 end
369 390
370 391 def <=>(project)
371 392 name.downcase <=> project.name.downcase
372 393 end
373 394
374 395 def to_s
375 396 name
376 397 end
377 398
378 399 # Returns a short description of the projects (first lines)
379 400 def short_description(length = 255)
380 401 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
381 402 end
382 403
383 404 # Return true if this project is allowed to do the specified action.
384 405 # action can be:
385 406 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
386 407 # * a permission Symbol (eg. :edit_project)
387 408 def allows_to?(action)
388 409 if action.is_a? Hash
389 410 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
390 411 else
391 412 allowed_permissions.include? action
392 413 end
393 414 end
394 415
395 416 def module_enabled?(module_name)
396 417 module_name = module_name.to_s
397 418 enabled_modules.detect {|m| m.name == module_name}
398 419 end
399 420
400 421 def enabled_module_names=(module_names)
401 422 if module_names && module_names.is_a?(Array)
402 423 module_names = module_names.collect(&:to_s)
403 424 # remove disabled modules
404 425 enabled_modules.each {|mod| mod.destroy unless module_names.include?(mod.name)}
405 426 # add new modules
406 427 module_names.reject {|name| module_enabled?(name)}.each {|name| enabled_modules << EnabledModule.new(:name => name)}
407 428 else
408 429 enabled_modules.clear
409 430 end
410 431 end
411 432
412 433 # Returns an auto-generated project identifier based on the last identifier used
413 434 def self.next_identifier
414 435 p = Project.find(:first, :order => 'created_on DESC')
415 436 p.nil? ? nil : p.identifier.to_s.succ
416 437 end
417 438
418 439 # Copies and saves the Project instance based on the +project+.
419 440 # Duplicates the source project's:
420 441 # * Wiki
421 442 # * Versions
422 443 # * Categories
423 444 # * Issues
424 445 # * Members
425 446 # * Queries
426 447 #
427 448 # Accepts an +options+ argument to specify what to copy
428 449 #
429 450 # Examples:
430 451 # project.copy(1) # => copies everything
431 452 # project.copy(1, :only => 'members') # => copies members only
432 453 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
433 454 def copy(project, options={})
434 455 project = project.is_a?(Project) ? project : Project.find(project)
435 456
436 457 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
437 458 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
438 459
439 460 Project.transaction do
440 461 if save
441 462 reload
442 463 to_be_copied.each do |name|
443 464 send "copy_#{name}", project
444 465 end
445 466 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
446 467 save
447 468 end
448 469 end
449 470 end
450 471
451 472
452 473 # Copies +project+ and returns the new instance. This will not save
453 474 # the copy
454 475 def self.copy_from(project)
455 476 begin
456 477 project = project.is_a?(Project) ? project : Project.find(project)
457 478 if project
458 479 # clear unique attributes
459 480 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
460 481 copy = Project.new(attributes)
461 482 copy.enabled_modules = project.enabled_modules
462 483 copy.trackers = project.trackers
463 484 copy.custom_values = project.custom_values.collect {|v| v.clone}
464 485 copy.issue_custom_fields = project.issue_custom_fields
465 486 return copy
466 487 else
467 488 return nil
468 489 end
469 490 rescue ActiveRecord::RecordNotFound
470 491 return nil
471 492 end
472 493 end
473 494
474 495 private
475 496
476 497 # Copies wiki from +project+
477 498 def copy_wiki(project)
478 499 # Check that the source project has a wiki first
479 500 unless project.wiki.nil?
480 501 self.wiki ||= Wiki.new
481 502 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
482 503 project.wiki.pages.each do |page|
483 504 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
484 505 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
485 506 new_wiki_page.content = new_wiki_content
486 507 wiki.pages << new_wiki_page
487 508 end
488 509 end
489 510 end
490 511
491 512 # Copies versions from +project+
492 513 def copy_versions(project)
493 514 project.versions.each do |version|
494 515 new_version = Version.new
495 516 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
496 517 self.versions << new_version
497 518 end
498 519 end
499 520
500 521 # Copies issue categories from +project+
501 522 def copy_issue_categories(project)
502 523 project.issue_categories.each do |issue_category|
503 524 new_issue_category = IssueCategory.new
504 525 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
505 526 self.issue_categories << new_issue_category
506 527 end
507 528 end
508 529
509 530 # Copies issues from +project+
510 531 def copy_issues(project)
511 532 project.issues.each do |issue|
512 533 new_issue = Issue.new
513 534 new_issue.copy_from(issue)
514 535 # Reassign fixed_versions by name, since names are unique per
515 536 # project and the versions for self are not yet saved
516 537 if issue.fixed_version
517 538 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
518 539 end
519 540 # Reassign the category by name, since names are unique per
520 541 # project and the categories for self are not yet saved
521 542 if issue.category
522 543 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
523 544 end
524 545 self.issues << new_issue
525 546 end
526 547 end
527 548
528 549 # Copies members from +project+
529 550 def copy_members(project)
530 551 project.members.each do |member|
531 552 new_member = Member.new
532 553 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
533 554 new_member.role_ids = member.role_ids.dup
534 555 new_member.project = self
535 556 self.members << new_member
536 557 end
537 558 end
538 559
539 560 # Copies queries from +project+
540 561 def copy_queries(project)
541 562 project.queries.each do |query|
542 563 new_query = Query.new
543 564 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
544 565 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
545 566 new_query.project = self
546 567 self.queries << new_query
547 568 end
548 569 end
549 570
550 571 # Copies boards from +project+
551 572 def copy_boards(project)
552 573 project.boards.each do |board|
553 574 new_board = Board.new
554 575 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
555 576 new_board.project = self
556 577 self.boards << new_board
557 578 end
558 579 end
559 580
560 581 def allowed_permissions
561 582 @allowed_permissions ||= begin
562 583 module_names = enabled_modules.collect {|m| m.name}
563 584 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
564 585 end
565 586 end
566 587
567 588 def allowed_actions
568 589 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
569 590 end
570 591
571 592 # Returns all the active Systemwide and project specific activities
572 593 def active_activities
573 594 overridden_activity_ids = self.time_entry_activities.active.collect(&:parent_id)
574 595
575 596 if overridden_activity_ids.empty?
576 597 return TimeEntryActivity.shared.active
577 598 else
578 599 return system_activities_and_project_overrides
579 600 end
580 601 end
581 602
582 603 # Returns all the Systemwide and project specific activities
583 604 # (inactive and active)
584 605 def all_activities
585 606 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
586 607
587 608 if overridden_activity_ids.empty?
588 609 return TimeEntryActivity.shared
589 610 else
590 611 return system_activities_and_project_overrides(true)
591 612 end
592 613 end
593 614
594 615 # Returns the systemwide active activities merged with the project specific overrides
595 616 def system_activities_and_project_overrides(include_inactive=false)
596 617 if include_inactive
597 618 return TimeEntryActivity.shared.
598 619 find(:all,
599 620 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
600 621 self.time_entry_activities
601 622 else
602 623 return TimeEntryActivity.shared.active.
603 624 find(:all,
604 625 :conditions => ["id NOT IN (?)", self.time_entry_activities.active.collect(&:parent_id)]) +
605 626 self.time_entry_activities.active
606 627 end
607 628 end
629
630 # Archives subprojects recursively
631 def archive!
632 children.each do |subproject|
633 subproject.send :archive!
634 end
635 update_attribute :status, STATUS_ARCHIVED
636 end
608 637 end
@@ -1,558 +1,558
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 QueryColumn
19 19 attr_accessor :name, :sortable, :groupable, :default_order
20 20 include Redmine::I18n
21 21
22 22 def initialize(name, options={})
23 23 self.name = name
24 24 self.sortable = options[:sortable]
25 25 self.groupable = options[:groupable] || false
26 26 if groupable == true
27 27 self.groupable = name.to_s
28 28 end
29 29 self.default_order = options[:default_order]
30 30 end
31 31
32 32 def caption
33 33 l("field_#{name}")
34 34 end
35 35
36 36 # Returns true if the column is sortable, otherwise false
37 37 def sortable?
38 38 !sortable.nil?
39 39 end
40 40
41 41 def value(issue)
42 42 issue.send name
43 43 end
44 44 end
45 45
46 46 class QueryCustomFieldColumn < QueryColumn
47 47
48 48 def initialize(custom_field)
49 49 self.name = "cf_#{custom_field.id}".to_sym
50 50 self.sortable = custom_field.order_statement || false
51 51 if %w(list date bool int).include?(custom_field.field_format)
52 52 self.groupable = custom_field.order_statement
53 53 end
54 54 self.groupable ||= false
55 55 @cf = custom_field
56 56 end
57 57
58 58 def caption
59 59 @cf.name
60 60 end
61 61
62 62 def custom_field
63 63 @cf
64 64 end
65 65
66 66 def value(issue)
67 67 cv = issue.custom_values.detect {|v| v.custom_field_id == @cf.id}
68 68 cv && @cf.cast_value(cv.value)
69 69 end
70 70 end
71 71
72 72 class Query < ActiveRecord::Base
73 73 class StatementInvalid < ::ActiveRecord::StatementInvalid
74 74 end
75 75
76 76 belongs_to :project
77 77 belongs_to :user
78 78 serialize :filters
79 79 serialize :column_names
80 80 serialize :sort_criteria, Array
81 81
82 82 attr_protected :project_id, :user_id
83 83
84 84 validates_presence_of :name, :on => :save
85 85 validates_length_of :name, :maximum => 255
86 86
87 87 @@operators = { "=" => :label_equals,
88 88 "!" => :label_not_equals,
89 89 "o" => :label_open_issues,
90 90 "c" => :label_closed_issues,
91 91 "!*" => :label_none,
92 92 "*" => :label_all,
93 93 ">=" => :label_greater_or_equal,
94 94 "<=" => :label_less_or_equal,
95 95 "<t+" => :label_in_less_than,
96 96 ">t+" => :label_in_more_than,
97 97 "t+" => :label_in,
98 98 "t" => :label_today,
99 99 "w" => :label_this_week,
100 100 ">t-" => :label_less_than_ago,
101 101 "<t-" => :label_more_than_ago,
102 102 "t-" => :label_ago,
103 103 "~" => :label_contains,
104 104 "!~" => :label_not_contains }
105 105
106 106 cattr_reader :operators
107 107
108 108 @@operators_by_filter_type = { :list => [ "=", "!" ],
109 109 :list_status => [ "o", "=", "!", "c", "*" ],
110 110 :list_optional => [ "=", "!", "!*", "*" ],
111 111 :list_subprojects => [ "*", "!*", "=" ],
112 112 :date => [ "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-" ],
113 113 :date_past => [ ">t-", "<t-", "t-", "t", "w" ],
114 114 :string => [ "=", "~", "!", "!~" ],
115 115 :text => [ "~", "!~" ],
116 116 :integer => [ "=", ">=", "<=", "!*", "*" ] }
117 117
118 118 cattr_reader :operators_by_filter_type
119 119
120 120 @@available_columns = [
121 121 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
122 122 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
123 123 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
124 124 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
125 125 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
126 126 QueryColumn.new(:author),
127 127 QueryColumn.new(:assigned_to, :sortable => ["#{User.table_name}.lastname", "#{User.table_name}.firstname", "#{User.table_name}.id"], :groupable => true),
128 128 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
129 129 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
130 130 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
131 131 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
132 132 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
133 133 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
134 134 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
135 135 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
136 136 ]
137 137 cattr_reader :available_columns
138 138
139 139 def initialize(attributes = nil)
140 140 super attributes
141 141 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
142 142 end
143 143
144 144 def after_initialize
145 145 # Store the fact that project is nil (used in #editable_by?)
146 146 @is_for_all = project.nil?
147 147 end
148 148
149 149 def validate
150 150 filters.each_key do |field|
151 151 errors.add label_for(field), :blank unless
152 152 # filter requires one or more values
153 153 (values_for(field) and !values_for(field).first.blank?) or
154 154 # filter doesn't require any value
155 155 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
156 156 end if filters
157 157 end
158 158
159 159 def editable_by?(user)
160 160 return false unless user
161 161 # Admin can edit them all and regular users can edit their private queries
162 162 return true if user.admin? || (!is_public && self.user_id == user.id)
163 163 # Members can not edit public queries that are for all project (only admin is allowed to)
164 164 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
165 165 end
166 166
167 167 def available_filters
168 168 return @available_filters if @available_filters
169 169
170 170 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
171 171
172 172 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
173 173 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
174 174 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
175 175 "subject" => { :type => :text, :order => 8 },
176 176 "created_on" => { :type => :date_past, :order => 9 },
177 177 "updated_on" => { :type => :date_past, :order => 10 },
178 178 "start_date" => { :type => :date, :order => 11 },
179 179 "due_date" => { :type => :date, :order => 12 },
180 180 "estimated_hours" => { :type => :integer, :order => 13 },
181 181 "done_ratio" => { :type => :integer, :order => 14 }}
182 182
183 183 user_values = []
184 184 user_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
185 185 if project
186 186 user_values += project.users.sort.collect{|s| [s.name, s.id.to_s] }
187 187 else
188 188 # members of the user's projects
189 189 user_values += User.current.projects.collect(&:users).flatten.uniq.sort.collect{|s| [s.name, s.id.to_s] }
190 190 end
191 191 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => user_values } unless user_values.empty?
192 192 @available_filters["author_id"] = { :type => :list, :order => 5, :values => user_values } unless user_values.empty?
193 193
194 194 if User.current.logged?
195 195 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
196 196 end
197 197
198 198 if project
199 199 # project specific filters
200 200 unless @project.issue_categories.empty?
201 201 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => @project.issue_categories.collect{|s| [s.name, s.id.to_s] } }
202 202 end
203 unless @project.versions.empty?
204 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => @project.versions.sort.collect{|s| [s.name, s.id.to_s] } }
203 unless @project.shared_versions.empty?
204 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => @project.shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
205 205 end
206 206 unless @project.descendants.active.empty?
207 207 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.descendants.visible.collect{|s| [s.name, s.id.to_s] } }
208 208 end
209 209 add_custom_fields_filters(@project.all_issue_custom_fields)
210 210 else
211 211 # global filters for cross project issue list
212 212 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
213 213 end
214 214 @available_filters
215 215 end
216 216
217 217 def add_filter(field, operator, values)
218 218 # values must be an array
219 219 return unless values and values.is_a? Array # and !values.first.empty?
220 220 # check if field is defined as an available filter
221 221 if available_filters.has_key? field
222 222 filter_options = available_filters[field]
223 223 # check if operator is allowed for that filter
224 224 #if @@operators_by_filter_type[filter_options[:type]].include? operator
225 225 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
226 226 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
227 227 #end
228 228 filters[field] = {:operator => operator, :values => values }
229 229 end
230 230 end
231 231
232 232 def add_short_filter(field, expression)
233 233 return unless expression
234 234 parms = expression.scan(/^(o|c|\!|\*)?(.*)$/).first
235 235 add_filter field, (parms[0] || "="), [parms[1] || ""]
236 236 end
237 237
238 238 def has_filter?(field)
239 239 filters and filters[field]
240 240 end
241 241
242 242 def operator_for(field)
243 243 has_filter?(field) ? filters[field][:operator] : nil
244 244 end
245 245
246 246 def values_for(field)
247 247 has_filter?(field) ? filters[field][:values] : nil
248 248 end
249 249
250 250 def label_for(field)
251 251 label = available_filters[field][:name] if available_filters.has_key?(field)
252 252 label ||= field.gsub(/\_id$/, "")
253 253 end
254 254
255 255 def available_columns
256 256 return @available_columns if @available_columns
257 257 @available_columns = Query.available_columns
258 258 @available_columns += (project ?
259 259 project.all_issue_custom_fields :
260 260 IssueCustomField.find(:all)
261 261 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
262 262 end
263 263
264 264 # Returns an array of columns that can be used to group the results
265 265 def groupable_columns
266 266 available_columns.select {|c| c.groupable}
267 267 end
268 268
269 269 def columns
270 270 if has_default_columns?
271 271 available_columns.select do |c|
272 272 # Adds the project column by default for cross-project lists
273 273 Setting.issue_list_default_columns.include?(c.name.to_s) || (c.name == :project && project.nil?)
274 274 end
275 275 else
276 276 # preserve the column_names order
277 277 column_names.collect {|name| available_columns.find {|col| col.name == name}}.compact
278 278 end
279 279 end
280 280
281 281 def column_names=(names)
282 282 if names
283 283 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
284 284 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
285 285 # Set column_names to nil if default columns
286 286 if names.map(&:to_s) == Setting.issue_list_default_columns
287 287 names = nil
288 288 end
289 289 end
290 290 write_attribute(:column_names, names)
291 291 end
292 292
293 293 def has_column?(column)
294 294 column_names && column_names.include?(column.name)
295 295 end
296 296
297 297 def has_default_columns?
298 298 column_names.nil? || column_names.empty?
299 299 end
300 300
301 301 def sort_criteria=(arg)
302 302 c = []
303 303 if arg.is_a?(Hash)
304 304 arg = arg.keys.sort.collect {|k| arg[k]}
305 305 end
306 306 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
307 307 write_attribute(:sort_criteria, c)
308 308 end
309 309
310 310 def sort_criteria
311 311 read_attribute(:sort_criteria) || []
312 312 end
313 313
314 314 def sort_criteria_key(arg)
315 315 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
316 316 end
317 317
318 318 def sort_criteria_order(arg)
319 319 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
320 320 end
321 321
322 322 # Returns the SQL sort order that should be prepended for grouping
323 323 def group_by_sort_order
324 324 if grouped? && (column = group_by_column)
325 325 column.sortable.is_a?(Array) ?
326 326 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
327 327 "#{column.sortable} #{column.default_order}"
328 328 end
329 329 end
330 330
331 331 # Returns true if the query is a grouped query
332 332 def grouped?
333 333 !group_by.blank?
334 334 end
335 335
336 336 def group_by_column
337 337 groupable_columns.detect {|c| c.name.to_s == group_by}
338 338 end
339 339
340 340 def group_by_statement
341 341 group_by_column.groupable
342 342 end
343 343
344 344 def project_statement
345 345 project_clauses = []
346 346 if project && !@project.descendants.active.empty?
347 347 ids = [project.id]
348 348 if has_filter?("subproject_id")
349 349 case operator_for("subproject_id")
350 350 when '='
351 351 # include the selected subprojects
352 352 ids += values_for("subproject_id").each(&:to_i)
353 353 when '!*'
354 354 # main project only
355 355 else
356 356 # all subprojects
357 357 ids += project.descendants.collect(&:id)
358 358 end
359 359 elsif Setting.display_subprojects_issues?
360 360 ids += project.descendants.collect(&:id)
361 361 end
362 362 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
363 363 elsif project
364 364 project_clauses << "#{Project.table_name}.id = %d" % project.id
365 365 end
366 366 project_clauses << Project.allowed_to_condition(User.current, :view_issues)
367 367 project_clauses.join(' AND ')
368 368 end
369 369
370 370 def statement
371 371 # filters clauses
372 372 filters_clauses = []
373 373 filters.each_key do |field|
374 374 next if field == "subproject_id"
375 375 v = values_for(field).clone
376 376 next unless v and !v.empty?
377 377 operator = operator_for(field)
378 378
379 379 # "me" value subsitution
380 380 if %w(assigned_to_id author_id watcher_id).include?(field)
381 381 v.push(User.current.logged? ? User.current.id.to_s : "0") if v.delete("me")
382 382 end
383 383
384 384 sql = ''
385 385 if field =~ /^cf_(\d+)$/
386 386 # custom field
387 387 db_table = CustomValue.table_name
388 388 db_field = 'value'
389 389 is_custom_filter = true
390 390 sql << "#{Issue.table_name}.id IN (SELECT #{Issue.table_name}.id FROM #{Issue.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='Issue' AND #{db_table}.customized_id=#{Issue.table_name}.id AND #{db_table}.custom_field_id=#{$1} WHERE "
391 391 sql << sql_for_field(field, operator, v, db_table, db_field, true) + ')'
392 392 elsif field == 'watcher_id'
393 393 db_table = Watcher.table_name
394 394 db_field = 'user_id'
395 395 sql << "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND "
396 396 sql << sql_for_field(field, '=', v, db_table, db_field) + ')'
397 397 else
398 398 # regular field
399 399 db_table = Issue.table_name
400 400 db_field = field
401 401 sql << '(' + sql_for_field(field, operator, v, db_table, db_field) + ')'
402 402 end
403 403 filters_clauses << sql
404 404
405 405 end if filters and valid?
406 406
407 407 (filters_clauses << project_statement).join(' AND ')
408 408 end
409 409
410 410 # Returns the issue count
411 411 def issue_count
412 412 Issue.count(:include => [:status, :project], :conditions => statement)
413 413 rescue ::ActiveRecord::StatementInvalid => e
414 414 raise StatementInvalid.new(e.message)
415 415 end
416 416
417 417 # Returns the issue count by group or nil if query is not grouped
418 418 def issue_count_by_group
419 419 r = nil
420 420 if grouped?
421 421 begin
422 422 # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value
423 423 r = Issue.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
424 424 rescue ActiveRecord::RecordNotFound
425 425 r = {nil => issue_count}
426 426 end
427 427 c = group_by_column
428 428 if c.is_a?(QueryCustomFieldColumn)
429 429 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
430 430 end
431 431 end
432 432 r
433 433 rescue ::ActiveRecord::StatementInvalid => e
434 434 raise StatementInvalid.new(e.message)
435 435 end
436 436
437 437 # Returns the issues
438 438 # Valid options are :order, :offset, :limit, :include, :conditions
439 439 def issues(options={})
440 440 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
441 441 order_option = nil if order_option.blank?
442 442
443 443 Issue.find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
444 444 :conditions => Query.merge_conditions(statement, options[:conditions]),
445 445 :order => order_option,
446 446 :limit => options[:limit],
447 447 :offset => options[:offset]
448 448 rescue ::ActiveRecord::StatementInvalid => e
449 449 raise StatementInvalid.new(e.message)
450 450 end
451 451
452 452 # Returns the journals
453 453 # Valid options are :order, :offset, :limit
454 454 def journals(options={})
455 455 Journal.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
456 456 :conditions => statement,
457 457 :order => options[:order],
458 458 :limit => options[:limit],
459 459 :offset => options[:offset]
460 460 rescue ::ActiveRecord::StatementInvalid => e
461 461 raise StatementInvalid.new(e.message)
462 462 end
463 463
464 464 # Returns the versions
465 465 # Valid options are :conditions
466 466 def versions(options={})
467 467 Version.find :all, :include => :project,
468 468 :conditions => Query.merge_conditions(project_statement, options[:conditions])
469 469 rescue ::ActiveRecord::StatementInvalid => e
470 470 raise StatementInvalid.new(e.message)
471 471 end
472 472
473 473 private
474 474
475 475 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
476 476 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
477 477 sql = ''
478 478 case operator
479 479 when "="
480 480 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
481 481 when "!"
482 482 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
483 483 when "!*"
484 484 sql = "#{db_table}.#{db_field} IS NULL"
485 485 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
486 486 when "*"
487 487 sql = "#{db_table}.#{db_field} IS NOT NULL"
488 488 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
489 489 when ">="
490 490 sql = "#{db_table}.#{db_field} >= #{value.first.to_i}"
491 491 when "<="
492 492 sql = "#{db_table}.#{db_field} <= #{value.first.to_i}"
493 493 when "o"
494 494 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_false}" if field == "status_id"
495 495 when "c"
496 496 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_true}" if field == "status_id"
497 497 when ">t-"
498 498 sql = date_range_clause(db_table, db_field, - value.first.to_i, 0)
499 499 when "<t-"
500 500 sql = date_range_clause(db_table, db_field, nil, - value.first.to_i)
501 501 when "t-"
502 502 sql = date_range_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
503 503 when ">t+"
504 504 sql = date_range_clause(db_table, db_field, value.first.to_i, nil)
505 505 when "<t+"
506 506 sql = date_range_clause(db_table, db_field, 0, value.first.to_i)
507 507 when "t+"
508 508 sql = date_range_clause(db_table, db_field, value.first.to_i, value.first.to_i)
509 509 when "t"
510 510 sql = date_range_clause(db_table, db_field, 0, 0)
511 511 when "w"
512 512 from = l(:general_first_day_of_week) == '7' ?
513 513 # week starts on sunday
514 514 ((Date.today.cwday == 7) ? Time.now.at_beginning_of_day : Time.now.at_beginning_of_week - 1.day) :
515 515 # week starts on monday (Rails default)
516 516 Time.now.at_beginning_of_week
517 517 sql = "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date(from), connection.quoted_date(from + 7.days)]
518 518 when "~"
519 519 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
520 520 when "!~"
521 521 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
522 522 end
523 523
524 524 return sql
525 525 end
526 526
527 527 def add_custom_fields_filters(custom_fields)
528 528 @available_filters ||= {}
529 529
530 530 custom_fields.select(&:is_filter?).each do |field|
531 531 case field.field_format
532 532 when "text"
533 533 options = { :type => :text, :order => 20 }
534 534 when "list"
535 535 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
536 536 when "date"
537 537 options = { :type => :date, :order => 20 }
538 538 when "bool"
539 539 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
540 540 else
541 541 options = { :type => :string, :order => 20 }
542 542 end
543 543 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name })
544 544 end
545 545 end
546 546
547 547 # Returns a SQL clause for a date or datetime field.
548 548 def date_range_clause(table, field, from, to)
549 549 s = []
550 550 if from
551 551 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date((Date.yesterday + from).to_time.end_of_day)])
552 552 end
553 553 if to
554 554 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date((Date.today + to).to_time.end_of_day)])
555 555 end
556 556 s.join(' AND ')
557 557 end
558 558 end
@@ -1,163 +1,205
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 Version < ActiveRecord::Base
19 19 before_destroy :check_integrity
20 after_update :update_issue_versions
20 21 belongs_to :project
21 22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id'
22 23 acts_as_customizable
23 24 acts_as_attachable :view_permission => :view_files,
24 25 :delete_permission => :manage_files
25 26
26 27 VERSION_STATUSES = %w(open locked closed)
28 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
27 29
28 30 validates_presence_of :name
29 31 validates_uniqueness_of :name, :scope => [:project_id]
30 32 validates_length_of :name, :maximum => 60
31 33 validates_format_of :effective_date, :with => /^\d{4}-\d{2}-\d{2}$/, :message => :not_a_date, :allow_nil => true
32 34 validates_inclusion_of :status, :in => VERSION_STATUSES
35 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
33 36
34 37 named_scope :open, :conditions => {:status => 'open'}
35
38 named_scope :visible, lambda {|*args| { :include => :project,
39 :conditions => Project.allowed_to_condition(args.first || User.current, :view_issues) } }
40
41 # Returns true if +user+ or current user is allowed to view the version
42 def visible?(user=User.current)
43 user.allowed_to?(:view_issues, self.project)
44 end
45
36 46 def start_date
37 47 effective_date
38 48 end
39 49
40 50 def due_date
41 51 effective_date
42 52 end
43 53
44 54 # Returns the total estimated time for this version
45 55 def estimated_hours
46 56 @estimated_hours ||= fixed_issues.sum(:estimated_hours).to_f
47 57 end
48 58
49 59 # Returns the total reported time for this version
50 60 def spent_hours
51 61 @spent_hours ||= TimeEntry.sum(:hours, :include => :issue, :conditions => ["#{Issue.table_name}.fixed_version_id = ?", id]).to_f
52 62 end
53 63
54 64 def closed?
55 65 status == 'closed'
56 66 end
67
68 def open?
69 status == 'open'
70 end
57 71
58 72 # Returns true if the version is completed: due date reached and no open issues
59 73 def completed?
60 74 effective_date && (effective_date <= Date.today) && (open_issues_count == 0)
61 75 end
62 76
63 77 # Returns the completion percentage of this version based on the amount of open/closed issues
64 78 # and the time spent on the open issues.
65 79 def completed_pourcent
66 80 if issues_count == 0
67 81 0
68 82 elsif open_issues_count == 0
69 83 100
70 84 else
71 85 issues_progress(false) + issues_progress(true)
72 86 end
73 87 end
74 88
75 89 # Returns the percentage of issues that have been marked as 'closed'.
76 90 def closed_pourcent
77 91 if issues_count == 0
78 92 0
79 93 else
80 94 issues_progress(false)
81 95 end
82 96 end
83 97
84 98 # Returns true if the version is overdue: due date reached and some open issues
85 99 def overdue?
86 100 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
87 101 end
88 102
89 103 # Returns assigned issues count
90 104 def issues_count
91 105 @issue_count ||= fixed_issues.count
92 106 end
93 107
94 108 # Returns the total amount of open issues for this version.
95 109 def open_issues_count
96 110 @open_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, false], :include => :status)
97 111 end
98 112
99 113 # Returns the total amount of closed issues for this version.
100 114 def closed_issues_count
101 115 @closed_issues_count ||= Issue.count(:all, :conditions => ["fixed_version_id = ? AND is_closed = ?", self.id, true], :include => :status)
102 116 end
103 117
104 118 def wiki_page
105 119 if project.wiki && !wiki_page_title.blank?
106 120 @wiki_page ||= project.wiki.find_page(wiki_page_title)
107 121 end
108 122 @wiki_page
109 123 end
110 124
111 125 def to_s; name end
112 126
113 127 # Versions are sorted by effective_date and name
114 128 # Those with no effective_date are at the end, sorted by name
115 129 def <=>(version)
116 130 if self.effective_date
117 131 version.effective_date ? (self.effective_date == version.effective_date ? self.name <=> version.name : self.effective_date <=> version.effective_date) : -1
118 132 else
119 133 version.effective_date ? 1 : (self.name <=> version.name)
120 134 end
121 135 end
122 136
137 # Returns the sharings that +user+ can set the version to
138 def allowed_sharings(user = User.current)
139 VERSION_SHARINGS.select do |s|
140 if sharing == s
141 true
142 else
143 case s
144 when 'system'
145 # Only admin users can set a systemwide sharing
146 user.admin?
147 when 'hierarchy', 'tree'
148 # Only users allowed to manage versions of the root project can
149 # set sharing to hierarchy or tree
150 project.nil? || user.allowed_to?(:manage_versions, project.root)
151 else
152 true
153 end
154 end
155 end
156 end
157
123 158 private
124 159 def check_integrity
125 160 raise "Can't delete version" if self.fixed_issues.find(:first)
126 161 end
162
163 # Update the issue's fixed versions. Used if a version's sharing changes.
164 def update_issue_versions
165 if sharing_changed?
166 Issue.update_fixed_versions_from_project_hierarchy_change
167 end
168 end
127 169
128 170 # Returns the average estimated time of assigned issues
129 171 # or 1 if no issue has an estimated time
130 172 # Used to weigth unestimated issues in progress calculation
131 173 def estimated_average
132 174 if @estimated_average.nil?
133 175 average = fixed_issues.average(:estimated_hours).to_f
134 176 if average == 0
135 177 average = 1
136 178 end
137 179 @estimated_average = average
138 180 end
139 181 @estimated_average
140 182 end
141 183
142 184 # Returns the total progress of open or closed issues. The returned percentage takes into account
143 185 # the amount of estimated time set for this version.
144 186 #
145 187 # Examples:
146 188 # issues_progress(true) => returns the progress percentage for open issues.
147 189 # issues_progress(false) => returns the progress percentage for closed issues.
148 190 def issues_progress(open)
149 191 @issues_progress ||= {}
150 192 @issues_progress[open] ||= begin
151 193 progress = 0
152 194 if issues_count > 0
153 195 ratio = open ? 'done_ratio' : 100
154 196
155 197 done = fixed_issues.sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}",
156 198 :include => :status,
157 199 :conditions => ["is_closed = ?", !open]).to_f
158 200 progress = done / (estimated_average * issues_count)
159 201 end
160 202 progress
161 203 end
162 204 end
163 205 end
@@ -1,36 +1,36
1 1 <% fields_for :issue, @issue, :builder => TabularFormBuilder do |f| %>
2 2
3 3 <div class="splitcontentleft">
4 4 <% if @issue.new_record? || @allowed_statuses.any? %>
5 5 <p><%= f.select :status_id, (@allowed_statuses.collect {|p| [p.name, p.id]}), :required => true %></p>
6 6 <% else %>
7 7 <p><label><%= l(:field_status) %></label> <%= @issue.status.name %></p>
8 8 <% end %>
9 9
10 10 <p><%= f.select :priority_id, (@priorities.collect {|p| [p.name, p.id]}), :required => true %></p>
11 11 <p><%= f.select :assigned_to_id, (@issue.assignable_users.collect {|m| [m.name, m.id]}), :include_blank => true %></p>
12 12 <% unless @project.issue_categories.empty? %>
13 13 <p><%= f.select :category_id, (@project.issue_categories.collect {|c| [c.name, c.id]}), :include_blank => true %>
14 14 <%= prompt_to_remote(image_tag('add.png', :style => 'vertical-align: middle;'),
15 15 l(:label_issue_category_new),
16 16 'category[name]',
17 17 {:controller => 'projects', :action => 'add_issue_category', :id => @project},
18 18 :title => l(:label_issue_category_new),
19 19 :tabindex => 199) if authorize_for('projects', 'add_issue_category') %></p>
20 20 <% end %>
21 21 <% unless @issue.assignable_versions.empty? %>
22 <p><%= f.select :fixed_version_id, (@issue.assignable_versions.collect {|v| [v.name, v.id]}), :include_blank => true %></p>
22 <p><%= f.select :fixed_version_id, version_options_for_select(@issue.assignable_versions, @issue.fixed_version), :include_blank => true %></p>
23 23 <% end %>
24 24 </div>
25 25
26 26 <div class="splitcontentright">
27 27 <p><%= f.text_field :start_date, :size => 10 %><%= calendar_for('issue_start_date') %></p>
28 28 <p><%= f.text_field :due_date, :size => 10 %><%= calendar_for('issue_due_date') %></p>
29 29 <p><%= f.text_field :estimated_hours, :size => 3 %> <%= l(:field_hours) %></p>
30 30 <p><%= f.select :done_ratio, ((0..10).to_a.collect {|r| ["#{r*10} %", r*10] }) %></p>
31 31 </div>
32 32
33 33 <div style="clear:both;"> </div>
34 34 <%= render :partial => 'form_custom_fields' %>
35 35
36 36 <% end %>
@@ -1,62 +1,62
1 1 <h2><%= l(:label_bulk_edit_selected_issues) %></h2>
2 2
3 3 <ul><%= @issues.collect {|i| content_tag('li', link_to(h("#{i.tracker} ##{i.id}"), { :action => 'show', :id => i }) + h(": #{i.subject}")) }.join("\n") %></ul>
4 4
5 5 <% form_tag() do %>
6 6 <%= @issues.collect {|i| hidden_field_tag('ids[]', i.id)}.join %>
7 7 <div class="box">
8 8 <fieldset>
9 9 <legend><%= l(:label_change_properties) %></legend>
10 10 <p>
11 11 <label><%= l(:field_tracker) %>:
12 12 <%= select_tag('tracker_id', "<option value=\"\">#{l(:label_no_change_option)}</option>" + options_from_collection_for_select(@project.trackers, :id, :name)) %></label>
13 13 <% if @available_statuses.any? %>
14 14 <label><%= l(:field_status) %>:
15 15 <%= select_tag('status_id', "<option value=\"\">#{l(:label_no_change_option)}</option>" + options_from_collection_for_select(@available_statuses, :id, :name)) %></label>
16 16 <% end %>
17 17 </p>
18 18 <p>
19 19 <label><%= l(:field_priority) %>:
20 20 <%= select_tag('priority_id', "<option value=\"\">#{l(:label_no_change_option)}</option>" + options_from_collection_for_select(IssuePriority.all, :id, :name)) %></label>
21 21 <label><%= l(:field_category) %>:
22 22 <%= select_tag('category_id', content_tag('option', l(:label_no_change_option), :value => '') +
23 23 content_tag('option', l(:label_none), :value => 'none') +
24 24 options_from_collection_for_select(@project.issue_categories, :id, :name)) %></label>
25 25 </p>
26 26 <p>
27 27 <label><%= l(:field_assigned_to) %>:
28 28 <%= select_tag('assigned_to_id', content_tag('option', l(:label_no_change_option), :value => '') +
29 29 content_tag('option', l(:label_nobody), :value => 'none') +
30 30 options_from_collection_for_select(@project.assignable_users, :id, :name)) %></label>
31 31 <label><%= l(:field_fixed_version) %>:
32 32 <%= select_tag('fixed_version_id', content_tag('option', l(:label_no_change_option), :value => '') +
33 33 content_tag('option', l(:label_none), :value => 'none') +
34 options_from_collection_for_select(@project.versions.open.sort, :id, :name)) %></label>
34 version_options_for_select(@project.shared_versions.open)) %></label>
35 35 </p>
36 36
37 37 <p>
38 38 <label><%= l(:field_start_date) %>:
39 39 <%= text_field_tag 'start_date', '', :size => 10 %><%= calendar_for('start_date') %></label>
40 40 <label><%= l(:field_due_date) %>:
41 41 <%= text_field_tag 'due_date', '', :size => 10 %><%= calendar_for('due_date') %></label>
42 42 <label><%= l(:field_done_ratio) %>:
43 43 <%= select_tag 'done_ratio', options_for_select([[l(:label_no_change_option), '']] + (0..10).to_a.collect {|r| ["#{r*10} %", r*10] }) %></label>
44 44 </p>
45 45
46 46 <% @custom_fields.each do |custom_field| %>
47 47 <p><label><%= h(custom_field.name) %></label>
48 48 <%= select_tag "custom_field_values[#{custom_field.id}]", options_for_select([[l(:label_no_change_option), '']] + custom_field.possible_values) %></label>
49 49 </p>
50 50 <% end %>
51 51
52 52 <%= call_hook(:view_issues_bulk_edit_details_bottom, { :issues => @issues }) %>
53 53 </fieldset>
54 54
55 55 <fieldset><legend><%= l(:field_notes) %></legend>
56 56 <%= text_area_tag 'notes', @notes, :cols => 60, :rows => 10, :class => 'wiki-edit' %>
57 57 <%= wikitoolbar_for 'notes' %>
58 58 </fieldset>
59 59 </div>
60 60
61 61 <p><%= submit_tag l(:button_submit) %>
62 62 <% end %>
@@ -1,114 +1,114
1 1 <ul>
2 2 <%= call_hook(:view_issues_context_menu_start, {:issues => @issues, :can => @can, :back => @back }) %>
3 3
4 4 <% if !@issue.nil? -%>
5 5 <li><%= context_menu_link l(:button_edit), {:controller => 'issues', :action => 'edit', :id => @issue},
6 6 :class => 'icon-edit', :disabled => !@can[:edit] %></li>
7 7 <li class="folder">
8 8 <a href="#" class="submenu" onclick="return false;"><%= l(:field_status) %></a>
9 9 <ul>
10 10 <% @statuses.each do |s| -%>
11 11 <li><%= context_menu_link s.name, {:controller => 'issues', :action => 'edit', :id => @issue, :issue => {:status_id => s}, :back_to => @back}, :method => :post,
12 12 :selected => (s == @issue.status), :disabled => !(@can[:update] && @allowed_statuses.include?(s)) %></li>
13 13 <% end -%>
14 14 </ul>
15 15 </li>
16 16 <% else %>
17 17 <li><%= context_menu_link l(:button_edit), {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id)},
18 18 :class => 'icon-edit', :disabled => !@can[:edit] %></li>
19 19 <% end %>
20 20
21 21 <% unless @trackers.nil? %>
22 22 <li class="folder">
23 23 <a href="#" class="submenu"><%= l(:field_tracker) %></a>
24 24 <ul>
25 25 <% @trackers.each do |t| -%>
26 26 <li><%= context_menu_link t.name, {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id), 'tracker_id' => t, :back_to => @back}, :method => :post,
27 27 :selected => (@issue && t == @issue.tracker), :disabled => !@can[:edit] %></li>
28 28 <% end -%>
29 29 </ul>
30 30 </li>
31 31 <% end %>
32 32 <li class="folder">
33 33 <a href="#" class="submenu"><%= l(:field_priority) %></a>
34 34 <ul>
35 35 <% @priorities.each do |p| -%>
36 36 <li><%= context_menu_link p.name, {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id), 'priority_id' => p, :back_to => @back}, :method => :post,
37 37 :selected => (@issue && p == @issue.priority), :disabled => !@can[:edit] %></li>
38 38 <% end -%>
39 39 </ul>
40 40 </li>
41 <% unless @project.nil? || @project.versions.open.empty? -%>
41 <% unless @project.nil? || @project.shared_versions.open.empty? -%>
42 42 <li class="folder">
43 43 <a href="#" class="submenu"><%= l(:field_fixed_version) %></a>
44 44 <ul>
45 <% @project.versions.open.sort.each do |v| -%>
46 <li><%= context_menu_link v.name, {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id), 'fixed_version_id' => v, :back_to => @back}, :method => :post,
45 <% @project.shared_versions.open.sort.each do |v| -%>
46 <li><%= context_menu_link format_version_name(v), {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id), 'fixed_version_id' => v, :back_to => @back}, :method => :post,
47 47 :selected => (@issue && v == @issue.fixed_version), :disabled => !@can[:update] %></li>
48 48 <% end -%>
49 49 <li><%= context_menu_link l(:label_none), {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id), 'fixed_version_id' => 'none', :back_to => @back}, :method => :post,
50 50 :selected => (@issue && @issue.fixed_version.nil?), :disabled => !@can[:update] %></li>
51 51 </ul>
52 52 </li>
53 53 <% end %>
54 54 <% unless @assignables.nil? || @assignables.empty? -%>
55 55 <li class="folder">
56 56 <a href="#" class="submenu"><%= l(:field_assigned_to) %></a>
57 57 <ul>
58 58 <% @assignables.each do |u| -%>
59 59 <li><%= context_menu_link u.name, {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id), 'assigned_to_id' => u, :back_to => @back}, :method => :post,
60 60 :selected => (@issue && u == @issue.assigned_to), :disabled => !@can[:update] %></li>
61 61 <% end -%>
62 62 <li><%= context_menu_link l(:label_nobody), {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id), 'assigned_to_id' => 'none', :back_to => @back}, :method => :post,
63 63 :selected => (@issue && @issue.assigned_to.nil?), :disabled => !@can[:update] %></li>
64 64 </ul>
65 65 </li>
66 66 <% end %>
67 67 <% unless @project.nil? || @project.issue_categories.empty? -%>
68 68 <li class="folder">
69 69 <a href="#" class="submenu"><%= l(:field_category) %></a>
70 70 <ul>
71 71 <% @project.issue_categories.each do |u| -%>
72 72 <li><%= context_menu_link u.name, {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id), 'category_id' => u, :back_to => @back}, :method => :post,
73 73 :selected => (@issue && u == @issue.category), :disabled => !@can[:update] %></li>
74 74 <% end -%>
75 75 <li><%= context_menu_link l(:label_none), {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id), 'category_id' => 'none', :back_to => @back}, :method => :post,
76 76 :selected => (@issue && @issue.category.nil?), :disabled => !@can[:update] %></li>
77 77 </ul>
78 78 </li>
79 79 <% end -%>
80 80 <li class="folder">
81 81 <a href="#" class="submenu"><%= l(:field_done_ratio) %></a>
82 82 <ul>
83 83 <% (0..10).map{|x|x*10}.each do |p| -%>
84 84 <li><%= context_menu_link "#{p}%", {:controller => 'issues', :action => 'bulk_edit', :ids => @issues.collect(&:id), 'done_ratio' => p, :back_to => @back}, :method => :post,
85 85 :selected => (@issue && p == @issue.done_ratio), :disabled => !@can[:edit] %></li>
86 86 <% end -%>
87 87 </ul>
88 88 </li>
89 89
90 90 <% if !@issue.nil? %>
91 91 <% if @can[:log_time] -%>
92 92 <li><%= context_menu_link l(:button_log_time), {:controller => 'timelog', :action => 'edit', :issue_id => @issue},
93 93 :class => 'icon-time-add' %></li>
94 94 <% end %>
95 95 <% if User.current.logged? %>
96 96 <li><%= watcher_link(@issue, User.current) %></li>
97 97 <% end %>
98 98 <% end %>
99 99
100 100 <% if @issue.present? %>
101 101 <li><%= context_menu_link l(:button_copy), {:controller => 'issues', :action => 'new', :project_id => @project, :copy_from => @issue},
102 102 :class => 'icon-copy', :disabled => !@can[:copy] %></li>
103 103 <% else %>
104 104 <li><%= context_menu_link l(:button_copy), {:controller => 'issues', :action => 'move', :ids => @issues.collect(&:id), :copy_options => {:copy => 't'}},
105 105 :class => 'icon-copy', :disabled => !@can[:move] %></li>
106 106 <% end %>
107 107
108 108 <li><%= context_menu_link l(:button_move), {:controller => 'issues', :action => 'move', :ids => @issues.collect(&:id)},
109 109 :class => 'icon-move', :disabled => !@can[:move] %></li>
110 110 <li><%= context_menu_link l(:button_delete), {:controller => 'issues', :action => 'destroy', :ids => @issues.collect(&:id)},
111 111 :method => :post, :confirm => l(:text_issues_destroy_confirmation), :class => 'icon-del', :disabled => !@can[:delete] %></li>
112 112
113 113 <%= call_hook(:view_issues_context_menu_end, {:issues => @issues, :can => @can, :back => @back }) %>
114 114 </ul>
@@ -1,61 +1,61
1 1 <h2><%= @author.nil? ? l(:label_activity) : l(:label_user_activity, link_to_user(@author)) %></h2>
2 2 <p class="subtitle"><%= l(:label_date_from_to, :start => format_date(@date_to - @days), :end => format_date(@date_to-1)) %></p>
3 3
4 4 <div id="activity">
5 5 <% @events_by_day.keys.sort.reverse.each do |day| %>
6 6 <h3><%= format_activity_day(day) %></h3>
7 7 <dl>
8 8 <% @events_by_day[day].sort {|x,y| y.event_datetime <=> x.event_datetime }.each do |e| -%>
9 9 <dt class="<%= e.event_type %> <%= User.current.logged? && e.respond_to?(:event_author) && User.current == e.event_author ? 'me' : nil %>">
10 10 <%= avatar(e.event_author, :size => "24") if e.respond_to?(:event_author) %>
11 11 <span class="time"><%= format_time(e.event_datetime, false) %></span>
12 12 <%= content_tag('span', h(e.project), :class => 'project') if @project.nil? || @project != e.project %>
13 13 <%= link_to format_activity_title(e.event_title), e.event_url %></dt>
14 14 <dd><span class="description"><%= format_activity_description(e.event_description) %></span>
15 15 <span class="author"><%= e.event_author if e.respond_to?(:event_author) %></span></dd>
16 16 <% end -%>
17 17 </dl>
18 18 <% end -%>
19 19 </div>
20 20
21 21 <%= content_tag('p', l(:label_no_data), :class => 'nodata') if @events_by_day.empty? %>
22 22
23 23 <div style="float:left;">
24 24 <%= link_to_remote(('&#171; ' + l(:label_previous)),
25 25 {:update => "content", :url => params.merge(:from => @date_to - @days - 1), :method => :get, :complete => 'window.scrollTo(0,0)'},
26 26 {:href => url_for(params.merge(:from => @date_to - @days - 1)),
27 27 :title => l(:label_date_from_to, :start => format_date(@date_to - 2*@days), :end => format_date(@date_to - @days - 1))}) %>
28 28 </div>
29 29 <div style="float:right;">
30 30 <%= link_to_remote((l(:label_next) + ' &#187;'),
31 31 {:update => "content", :url => params.merge(:from => @date_to + @days - 1), :method => :get, :complete => 'window.scrollTo(0,0)'},
32 32 {:href => url_for(params.merge(:from => @date_to + @days - 1)),
33 33 :title => l(:label_date_from_to, :start => format_date(@date_to), :end => format_date(@date_to + @days - 1))}) unless @date_to >= Date.today %>
34 34 </div>
35 35 &nbsp;
36 36 <% other_formats_links do |f| %>
37 37 <%= f.link_to 'Atom', :url => params.merge(:from => nil, :key => User.current.rss_key) %>
38 38 <% end %>
39 39
40 40 <% content_for :header_tags do %>
41 41 <%= auto_discovery_link_tag(:atom, params.merge(:format => 'atom', :from => nil, :key => User.current.rss_key)) %>
42 42 <% end %>
43 43
44 44 <% content_for :sidebar do %>
45 45 <% form_tag({}, :method => :get) do %>
46 46 <h3><%= l(:label_activity) %></h3>
47 47 <p><% @activity.event_types.each do |t| %>
48 48 <%= check_box_tag "show_#{t}", 1, @activity.scope.include?(t) %>
49 49 <%= link_to(l("label_#{t.singularize}_plural"), {"show_#{t}" => 1, :user_id => params[:user_id]})%>
50 50 <br />
51 51 <% end %></p>
52 52 <% if @project && @project.descendants.active.any? %>
53 <%= hidden_field_tag 'with_subprojects', 0 %>
53 54 <p><label><%= check_box_tag 'with_subprojects', 1, @with_subprojects %> <%=l(:label_subproject_plural)%></label></p>
54 <%= hidden_field_tag 'with_subprojects', 0 %>
55 55 <% end %>
56 56 <%= hidden_field_tag('user_id', params[:user_id]) unless params[:user_id].blank? %>
57 57 <p><%= submit_tag l(:button_apply), :class => 'button-small', :name => nil %></p>
58 58 <% end %>
59 59 <% end %>
60 60
61 61 <% html_title(l(:label_activity), @author) -%>
@@ -1,43 +1,47
1 1 <h2><%=l(:label_change_log)%></h2>
2 2
3 3 <% if @versions.empty? %>
4 4 <p class="nodata"><%= l(:label_no_data) %></p>
5 5 <% end %>
6 6
7 7 <% @versions.each do |version| %>
8 8 <%= tag 'a', :name => version.name %>
9 <h3 class="icon22 icon22-package"><%= link_to h(version.name), :controller => 'versions', :action => 'show', :id => version %></h3>
9 <h3 class="icon22 icon22-package"><%= link_to_version version %></h3>
10 10 <% if version.effective_date %>
11 11 <p><%= format_date(version.effective_date) %></p>
12 12 <% end %>
13 13 <p><%=h version.description %></p>
14 <% issues = version.fixed_issues.find(:all,
15 :include => [:status, :tracker, :priority],
16 :conditions => ["#{IssueStatus.table_name}.is_closed=? AND #{Issue.table_name}.tracker_id in (#{@selected_tracker_ids.join(',')})", true],
17 :order => "#{Tracker.table_name}.position") unless @selected_tracker_ids.empty?
14 <% issues = version.fixed_issues.visible.find(:all,
15 :include => [:status, :tracker, :priority],
16 :conditions => ["#{Issue.table_name}.project_id = ? AND #{IssueStatus.table_name}.is_closed=? AND #{Issue.table_name}.tracker_id in (?)", @project.id, true, @selected_tracker_ids],
17 :order => "#{Tracker.table_name}.position") unless @selected_tracker_ids.empty?
18 18 issues ||= []
19 19 %>
20 20 <% if !issues.empty? %>
21 21 <ul>
22 22 <% issues.each do |issue| %>
23 23 <li><%= link_to_issue(issue) %></li>
24 24 <% end %>
25 25 </ul>
26 26 <% end %>
27 27 <% end %>
28 28
29 29 <% content_for :sidebar do %>
30 30 <% form_tag({},:method => :get) do %>
31 31 <h3><%= l(:label_change_log) %></h3>
32 32 <% @trackers.each do |tracker| %>
33 33 <label><%= check_box_tag "tracker_ids[]", tracker.id, (@selected_tracker_ids.include? tracker.id.to_s) %>
34 34 <%= tracker.name %></label><br />
35 35 <% end %>
36 <% if @project.descendants.active.any? %>
37 <%= hidden_field_tag 'with_subprojects', 0 %>
38 <br /><label><%= check_box_tag 'with_subprojects', 1, @with_subprojects %> <%=l(:label_subproject_plural)%></label>
39 <% end %>
36 40 <p><%= submit_tag l(:button_apply), :class => 'button-small' %></p>
37 41 <% end %>
38 42
39 43 <h3><%= l(:label_version_plural) %></h3>
40 44 <% @versions.each do |version| %>
41 <%= link_to version.name, :anchor => version.name %><br />
45 <%= link_to format_version_name(version), :anchor => version.name %><br />
42 46 <% end %>
43 47 <% end %>
@@ -1,51 +1,49
1 1 <h2><%=l(:label_roadmap)%></h2>
2 2
3 3 <% if @versions.empty? %>
4 4 <p class="nodata"><%= l(:label_no_data) %></p>
5 5 <% else %>
6 6 <div id="roadmap">
7 7 <% @versions.each do |version| %>
8 8 <%= tag 'a', :name => version.name %>
9 <h3 class="icon22 icon22-package"><%= link_to h(version.name), :controller => 'versions', :action => 'show', :id => version %></h3>
9 <h3 class="icon22 icon22-package"><%= link_to_version version %></h3>
10 10 <%= render :partial => 'versions/overview', :locals => {:version => version} %>
11 11 <%= render(:partial => "wiki/content", :locals => {:content => version.wiki_page.content}) if version.wiki_page %>
12 12
13 <% issues = version.fixed_issues.find(:all,
14 :include => [:status, :tracker, :priority],
15 :conditions => ["tracker_id in (#{@selected_tracker_ids.join(',')})"],
16 :order => "#{Tracker.table_name}.position, #{Issue.table_name}.id") unless @selected_tracker_ids.empty?
17 issues ||= []
18 %>
19 <% if issues.size > 0 %>
13 <% if (issues = @issues_by_version[version]) && issues.size > 0 %>
20 14 <fieldset class="related-issues"><legend><%= l(:label_related_issues) %></legend>
21 15 <ul>
22 16 <%- issues.each do |issue| -%>
23 17 <li><%= link_to_issue(issue) %></li>
24 18 <%- end -%>
25 19 </ul>
26 20 </fieldset>
27 21 <% end %>
28 22 <%= call_hook :view_projects_roadmap_version_bottom, :version => version %>
29 23 <% end %>
30 24 </div>
31 25 <% end %>
32 26
33 27 <% content_for :sidebar do %>
34 28 <% form_tag({}, :method => :get) do %>
35 29 <h3><%= l(:label_roadmap) %></h3>
36 30 <% @trackers.each do |tracker| %>
37 31 <label><%= check_box_tag "tracker_ids[]", tracker.id, (@selected_tracker_ids.include? tracker.id.to_s), :id => nil %>
38 32 <%= tracker.name %></label><br />
39 33 <% end %>
40 34 <br />
41 35 <label for="completed"><%= check_box_tag "completed", 1, params[:completed] %> <%= l(:label_show_completed_versions) %></label>
36 <% if @project.descendants.active.any? %>
37 <%= hidden_field_tag 'with_subprojects', 0 %>
38 <br /><label><%= check_box_tag 'with_subprojects', 1, @with_subprojects %> <%=l(:label_subproject_plural)%></label>
39 <% end %>
42 40 <p><%= submit_tag l(:button_apply), :class => 'button-small', :name => nil %></p>
43 41 <% end %>
44 42
45 43 <h3><%= l(:label_version_plural) %></h3>
46 44 <% @versions.each do |version| %>
47 <%= link_to version.name, "##{version.name}" %><br />
45 <%= link_to format_version_name(version), "##{version.name}" %><br />
48 46 <% end %>
49 47 <% end %>
50 48
51 49 <% html_title(l(:label_roadmap)) %>
@@ -1,37 +1,39
1 1 <% if @project.versions.any? %>
2 2 <table class="list versions">
3 3 <thead>
4 4 <th><%= l(:label_version) %></th>
5 5 <th><%= l(:field_effective_date) %></th>
6 6 <th><%= l(:field_description) %></th>
7 7 <th><%= l(:field_status) %></th>
8 <th><%= l(:field_sharing) %></th>
8 9 <th><%= l(:label_wiki_page) unless @project.wiki.nil? %></th>
9 10 <th style="width:15%"></th>
10 11 </thead>
11 12 <tbody>
12 13 <% for version in @project.versions.sort %>
13 14 <tr class="version <%= cycle 'odd', 'even' %> <%=h version.status %>">
14 15 <td><%= link_to h(version.name), :controller => 'versions', :action => 'show', :id => version %></td>
15 16 <td align="center"><%= format_date(version.effective_date) %></td>
16 17 <td><%=h version.description %></td>
17 18 <td><%= l("version_status_#{version.status}") %></td>
19 <td><%=h format_version_sharing(version.sharing) %></td>
18 20 <td><%= link_to(h(version.wiki_page_title), :controller => 'wiki', :page => Wiki.titleize(version.wiki_page_title)) unless version.wiki_page_title.blank? || @project.wiki.nil? %></td>
19 21 <td class="buttons">
20 22 <%= link_to_if_authorized l(:button_edit), {:controller => 'versions', :action => 'edit', :id => version }, :class => 'icon icon-edit' %>
21 23 <%= link_to_if_authorized l(:button_delete), {:controller => 'versions', :action => 'destroy', :id => version}, :confirm => l(:text_are_you_sure), :method => :post, :class => 'icon icon-del' %>
22 24 </td>
23 25 </tr>
24 26 <% end; reset_cycle %>
25 27 </tbody>
26 28 </table>
27 29 <% else %>
28 30 <p class="nodata"><%= l(:label_no_data) %></p>
29 31 <% end %>
30 32
31 33 <div class="contextual">
32 34 <% if @project.versions.any? %>
33 35 <%= link_to 'Close completed versions', {:controller => 'versions', :action => 'close_completed', :project_id => @project}, :method => :post %>
34 36 <% end %>
35 37 </div>
36 38
37 39 <p><%= link_to_if_authorized l(:label_version_new), :controller => 'projects', :action => 'add_version', :id => @project %></p>
@@ -1,13 +1,15
1 1 <%= error_messages_for 'version' %>
2 2
3 3 <div class="box">
4 4 <p><%= f.text_field :name, :size => 60, :required => true %></p>
5 5 <p><%= f.text_field :description, :size => 60 %></p>
6 6 <p><%= f.select :status, Version::VERSION_STATUSES.collect {|s| [l("version_status_#{s}"), s]} %></p>
7 7 <p><%= f.text_field :wiki_page_title, :label => :label_wiki_page, :size => 60, :disabled => @project.wiki.nil? %></p>
8 8 <p><%= f.text_field :effective_date, :size => 10 %><%= calendar_for('version_effective_date') %></p>
9 <p><%= f.select :sharing, @version.allowed_sharings.collect {|v| [format_version_sharing(v), v]} %></p>
9 10
10 11 <% @version.custom_field_values.each do |value| %>
11 12 <p><%= custom_field_tag_with_label :version, value %></p>
12 13 <% end %>
14
13 15 </div>
@@ -1,843 +1,850
1 1 en:
2 2 date:
3 3 formats:
4 4 # Use the strftime parameters for formats.
5 5 # When no format has been given, it uses default.
6 6 # You can provide other formats here if you like!
7 7 default: "%m/%d/%Y"
8 8 short: "%b %d"
9 9 long: "%B %d, %Y"
10 10
11 11 day_names: [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday]
12 12 abbr_day_names: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
13 13
14 14 # Don't forget the nil at the beginning; there's no such thing as a 0th month
15 15 month_names: [~, January, February, March, April, May, June, July, August, September, October, November, December]
16 16 abbr_month_names: [~, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
17 17 # Used in date_select and datime_select.
18 18 order: [ :year, :month, :day ]
19 19
20 20 time:
21 21 formats:
22 22 default: "%m/%d/%Y %I:%M %p"
23 23 time: "%I:%M %p"
24 24 short: "%d %b %H:%M"
25 25 long: "%B %d, %Y %H:%M"
26 26 am: "am"
27 27 pm: "pm"
28 28
29 29 datetime:
30 30 distance_in_words:
31 31 half_a_minute: "half a minute"
32 32 less_than_x_seconds:
33 33 one: "less than 1 second"
34 34 other: "less than {{count}} seconds"
35 35 x_seconds:
36 36 one: "1 second"
37 37 other: "{{count}} seconds"
38 38 less_than_x_minutes:
39 39 one: "less than a minute"
40 40 other: "less than {{count}} minutes"
41 41 x_minutes:
42 42 one: "1 minute"
43 43 other: "{{count}} minutes"
44 44 about_x_hours:
45 45 one: "about 1 hour"
46 46 other: "about {{count}} hours"
47 47 x_days:
48 48 one: "1 day"
49 49 other: "{{count}} days"
50 50 about_x_months:
51 51 one: "about 1 month"
52 52 other: "about {{count}} months"
53 53 x_months:
54 54 one: "1 month"
55 55 other: "{{count}} months"
56 56 about_x_years:
57 57 one: "about 1 year"
58 58 other: "about {{count}} years"
59 59 over_x_years:
60 60 one: "over 1 year"
61 61 other: "over {{count}} years"
62 62
63 63 number:
64 64 human:
65 65 format:
66 66 delimiter: ""
67 67 precision: 1
68 68 storage_units:
69 69 format: "%n %u"
70 70 units:
71 71 byte:
72 72 one: "Byte"
73 73 other: "Bytes"
74 74 kb: "KB"
75 75 mb: "MB"
76 76 gb: "GB"
77 77 tb: "TB"
78 78
79 79
80 80 # Used in array.to_sentence.
81 81 support:
82 82 array:
83 83 sentence_connector: "and"
84 84 skip_last_comma: false
85 85
86 86 activerecord:
87 87 errors:
88 88 messages:
89 89 inclusion: "is not included in the list"
90 90 exclusion: "is reserved"
91 91 invalid: "is invalid"
92 92 confirmation: "doesn't match confirmation"
93 93 accepted: "must be accepted"
94 94 empty: "can't be empty"
95 95 blank: "can't be blank"
96 96 too_long: "is too long (maximum is {{count}} characters)"
97 97 too_short: "is too short (minimum is {{count}} characters)"
98 98 wrong_length: "is the wrong length (should be {{count}} characters)"
99 99 taken: "has already been taken"
100 100 not_a_number: "is not a number"
101 101 not_a_date: "is not a valid date"
102 102 greater_than: "must be greater than {{count}}"
103 103 greater_than_or_equal_to: "must be greater than or equal to {{count}}"
104 104 equal_to: "must be equal to {{count}}"
105 105 less_than: "must be less than {{count}}"
106 106 less_than_or_equal_to: "must be less than or equal to {{count}}"
107 107 odd: "must be odd"
108 108 even: "must be even"
109 109 greater_than_start_date: "must be greater than start date"
110 110 not_same_project: "doesn't belong to the same project"
111 111 circular_dependency: "This relation would create a circular dependency"
112 112
113 113 actionview_instancetag_blank_option: Please select
114 114
115 115 general_text_No: 'No'
116 116 general_text_Yes: 'Yes'
117 117 general_text_no: 'no'
118 118 general_text_yes: 'yes'
119 119 general_lang_name: 'English'
120 120 general_csv_separator: ','
121 121 general_csv_decimal_separator: '.'
122 122 general_csv_encoding: ISO-8859-1
123 123 general_pdf_encoding: ISO-8859-1
124 124 general_first_day_of_week: '7'
125 125
126 126 notice_account_updated: Account was successfully updated.
127 127 notice_account_invalid_creditentials: Invalid user or password
128 128 notice_account_password_updated: Password was successfully updated.
129 129 notice_account_wrong_password: Wrong password
130 130 notice_account_register_done: Account was successfully created. To activate your account, click on the link that was emailed to you.
131 131 notice_account_unknown_email: Unknown user.
132 132 notice_can_t_change_password: This account uses an external authentication source. Impossible to change the password.
133 133 notice_account_lost_email_sent: An email with instructions to choose a new password has been sent to you.
134 134 notice_account_activated: Your account has been activated. You can now log in.
135 135 notice_successful_create: Successful creation.
136 136 notice_successful_update: Successful update.
137 137 notice_successful_delete: Successful deletion.
138 138 notice_successful_connection: Successful connection.
139 139 notice_file_not_found: The page you were trying to access doesn't exist or has been removed.
140 140 notice_locking_conflict: Data has been updated by another user.
141 141 notice_not_authorized: You are not authorized to access this page.
142 142 notice_email_sent: "An email was sent to {{value}}"
143 143 notice_email_error: "An error occurred while sending mail ({{value}})"
144 144 notice_feeds_access_key_reseted: Your RSS access key was reset.
145 145 notice_failed_to_save_issues: "Failed to save {{count}} issue(s) on {{total}} selected: {{ids}}."
146 146 notice_no_issue_selected: "No issue is selected! Please, check the issues you want to edit."
147 147 notice_account_pending: "Your account was created and is now pending administrator approval."
148 148 notice_default_data_loaded: Default configuration successfully loaded.
149 149 notice_unable_delete_version: Unable to delete version.
150 150
151 151 error_can_t_load_default_data: "Default configuration could not be loaded: {{value}}"
152 152 error_scm_not_found: "The entry or revision was not found in the repository."
153 153 error_scm_command_failed: "An error occurred when trying to access the repository: {{value}}"
154 154 error_scm_annotate: "The entry does not exist or can not be annotated."
155 155 error_issue_not_found_in_project: 'The issue was not found or does not belong to this project'
156 156 error_no_tracker_in_project: 'No tracker is associated to this project. Please check the Project settings.'
157 157 error_no_default_issue_status: 'No default issue status is defined. Please check your configuration (Go to "Administration -> Issue statuses").'
158 158 error_can_not_reopen_issue_on_closed_version: 'An issue assigned to a closed version can not be reopened'
159 error_can_not_archive_project: This project can not be archived
159 160
160 161 warning_attachments_not_saved: "{{count}} file(s) could not be saved."
161 162
162 163 mail_subject_lost_password: "Your {{value}} password"
163 164 mail_body_lost_password: 'To change your password, click on the following link:'
164 165 mail_subject_register: "Your {{value}} account activation"
165 166 mail_body_register: 'To activate your account, click on the following link:'
166 167 mail_body_account_information_external: "You can use your {{value}} account to log in."
167 168 mail_body_account_information: Your account information
168 169 mail_subject_account_activation_request: "{{value}} account activation request"
169 170 mail_body_account_activation_request: "A new user ({{value}}) has registered. The account is pending your approval:"
170 171 mail_subject_reminder: "{{count}} issue(s) due in the next days"
171 172 mail_body_reminder: "{{count}} issue(s) that are assigned to you are due in the next {{days}} days:"
172 173 mail_subject_wiki_content_added: "'{{page}}' wiki page has been added"
173 174 mail_body_wiki_content_added: "The '{{page}}' wiki page has been added by {{author}}."
174 175 mail_subject_wiki_content_updated: "'{{page}}' wiki page has been updated"
175 176 mail_body_wiki_content_updated: "The '{{page}}' wiki page has been updated by {{author}}."
176 177
177 178 gui_validation_error: 1 error
178 179 gui_validation_error_plural: "{{count}} errors"
179 180
180 181 field_name: Name
181 182 field_description: Description
182 183 field_summary: Summary
183 184 field_is_required: Required
184 185 field_firstname: Firstname
185 186 field_lastname: Lastname
186 187 field_mail: Email
187 188 field_filename: File
188 189 field_filesize: Size
189 190 field_downloads: Downloads
190 191 field_author: Author
191 192 field_created_on: Created
192 193 field_updated_on: Updated
193 194 field_field_format: Format
194 195 field_is_for_all: For all projects
195 196 field_possible_values: Possible values
196 197 field_regexp: Regular expression
197 198 field_min_length: Minimum length
198 199 field_max_length: Maximum length
199 200 field_value: Value
200 201 field_category: Category
201 202 field_title: Title
202 203 field_project: Project
203 204 field_issue: Issue
204 205 field_status: Status
205 206 field_notes: Notes
206 207 field_is_closed: Issue closed
207 208 field_is_default: Default value
208 209 field_tracker: Tracker
209 210 field_subject: Subject
210 211 field_due_date: Due date
211 212 field_assigned_to: Assigned to
212 213 field_priority: Priority
213 214 field_fixed_version: Target version
214 215 field_user: User
215 216 field_role: Role
216 217 field_homepage: Homepage
217 218 field_is_public: Public
218 219 field_parent: Subproject of
219 220 field_is_in_chlog: Issues displayed in changelog
220 221 field_is_in_roadmap: Issues displayed in roadmap
221 222 field_login: Login
222 223 field_mail_notification: Email notifications
223 224 field_admin: Administrator
224 225 field_last_login_on: Last connection
225 226 field_language: Language
226 227 field_effective_date: Date
227 228 field_password: Password
228 229 field_new_password: New password
229 230 field_password_confirmation: Confirmation
230 231 field_version: Version
231 232 field_type: Type
232 233 field_host: Host
233 234 field_port: Port
234 235 field_account: Account
235 236 field_base_dn: Base DN
236 237 field_attr_login: Login attribute
237 238 field_attr_firstname: Firstname attribute
238 239 field_attr_lastname: Lastname attribute
239 240 field_attr_mail: Email attribute
240 241 field_onthefly: On-the-fly user creation
241 242 field_start_date: Start
242 243 field_done_ratio: % Done
243 244 field_auth_source: Authentication mode
244 245 field_hide_mail: Hide my email address
245 246 field_comments: Comment
246 247 field_url: URL
247 248 field_start_page: Start page
248 249 field_subproject: Subproject
249 250 field_hours: Hours
250 251 field_activity: Activity
251 252 field_spent_on: Date
252 253 field_identifier: Identifier
253 254 field_is_filter: Used as a filter
254 255 field_issue_to: Related issue
255 256 field_delay: Delay
256 257 field_assignable: Issues can be assigned to this role
257 258 field_redirect_existing_links: Redirect existing links
258 259 field_estimated_hours: Estimated time
259 260 field_column_names: Columns
260 261 field_time_zone: Time zone
261 262 field_searchable: Searchable
262 263 field_default_value: Default value
263 264 field_comments_sorting: Display comments
264 265 field_parent_title: Parent page
265 266 field_editable: Editable
266 267 field_watcher: Watcher
267 268 field_identity_url: OpenID URL
268 269 field_content: Content
269 270 field_group_by: Group results by
271 field_sharing: Sharing
270 272
271 273 setting_app_title: Application title
272 274 setting_app_subtitle: Application subtitle
273 275 setting_welcome_text: Welcome text
274 276 setting_default_language: Default language
275 277 setting_login_required: Authentication required
276 278 setting_self_registration: Self-registration
277 279 setting_attachment_max_size: Attachment max. size
278 280 setting_issues_export_limit: Issues export limit
279 281 setting_mail_from: Emission email address
280 282 setting_bcc_recipients: Blind carbon copy recipients (bcc)
281 283 setting_plain_text_mail: Plain text mail (no HTML)
282 284 setting_host_name: Host name and path
283 285 setting_text_formatting: Text formatting
284 286 setting_wiki_compression: Wiki history compression
285 287 setting_feeds_limit: Feed content limit
286 288 setting_default_projects_public: New projects are public by default
287 289 setting_autofetch_changesets: Autofetch commits
288 290 setting_sys_api_enabled: Enable WS for repository management
289 291 setting_commit_ref_keywords: Referencing keywords
290 292 setting_commit_fix_keywords: Fixing keywords
291 293 setting_autologin: Autologin
292 294 setting_date_format: Date format
293 295 setting_time_format: Time format
294 296 setting_cross_project_issue_relations: Allow cross-project issue relations
295 297 setting_issue_list_default_columns: Default columns displayed on the issue list
296 298 setting_repositories_encodings: Repositories encodings
297 299 setting_commit_logs_encoding: Commit messages encoding
298 300 setting_emails_footer: Emails footer
299 301 setting_protocol: Protocol
300 302 setting_per_page_options: Objects per page options
301 303 setting_user_format: Users display format
302 304 setting_activity_days_default: Days displayed on project activity
303 305 setting_display_subprojects_issues: Display subprojects issues on main projects by default
304 306 setting_enabled_scm: Enabled SCM
305 307 setting_mail_handler_api_enabled: Enable WS for incoming emails
306 308 setting_mail_handler_api_key: API key
307 309 setting_sequential_project_identifiers: Generate sequential project identifiers
308 310 setting_gravatar_enabled: Use Gravatar user icons
309 311 setting_gravatar_default: Default Gravatar image
310 312 setting_diff_max_lines_displayed: Max number of diff lines displayed
311 313 setting_file_max_size_displayed: Max size of text files displayed inline
312 314 setting_repository_log_display_limit: Maximum number of revisions displayed on file log
313 315 setting_openid: Allow OpenID login and registration
314 316 setting_password_min_length: Minimum password length
315 317 setting_new_project_user_role_id: Role given to a non-admin user who creates a project
316 318 setting_default_projects_modules: Default enabled modules for new projects
317 319
318 320 permission_add_project: Create project
319 321 permission_edit_project: Edit project
320 322 permission_select_project_modules: Select project modules
321 323 permission_manage_members: Manage members
322 324 permission_manage_versions: Manage versions
323 325 permission_manage_categories: Manage issue categories
324 326 permission_add_issues: Add issues
325 327 permission_edit_issues: Edit issues
326 328 permission_manage_issue_relations: Manage issue relations
327 329 permission_add_issue_notes: Add notes
328 330 permission_edit_issue_notes: Edit notes
329 331 permission_edit_own_issue_notes: Edit own notes
330 332 permission_move_issues: Move issues
331 333 permission_delete_issues: Delete issues
332 334 permission_manage_public_queries: Manage public queries
333 335 permission_save_queries: Save queries
334 336 permission_view_gantt: View gantt chart
335 337 permission_view_calendar: View calendar
336 338 permission_view_issue_watchers: View watchers list
337 339 permission_add_issue_watchers: Add watchers
338 340 permission_delete_issue_watchers: Delete watchers
339 341 permission_log_time: Log spent time
340 342 permission_view_time_entries: View spent time
341 343 permission_edit_time_entries: Edit time logs
342 344 permission_edit_own_time_entries: Edit own time logs
343 345 permission_manage_news: Manage news
344 346 permission_comment_news: Comment news
345 347 permission_manage_documents: Manage documents
346 348 permission_view_documents: View documents
347 349 permission_manage_files: Manage files
348 350 permission_view_files: View files
349 351 permission_manage_wiki: Manage wiki
350 352 permission_rename_wiki_pages: Rename wiki pages
351 353 permission_delete_wiki_pages: Delete wiki pages
352 354 permission_view_wiki_pages: View wiki
353 355 permission_view_wiki_edits: View wiki history
354 356 permission_edit_wiki_pages: Edit wiki pages
355 357 permission_delete_wiki_pages_attachments: Delete attachments
356 358 permission_protect_wiki_pages: Protect wiki pages
357 359 permission_manage_repository: Manage repository
358 360 permission_browse_repository: Browse repository
359 361 permission_view_changesets: View changesets
360 362 permission_commit_access: Commit access
361 363 permission_manage_boards: Manage boards
362 364 permission_view_messages: View messages
363 365 permission_add_messages: Post messages
364 366 permission_edit_messages: Edit messages
365 367 permission_edit_own_messages: Edit own messages
366 368 permission_delete_messages: Delete messages
367 369 permission_delete_own_messages: Delete own messages
368 370
369 371 project_module_issue_tracking: Issue tracking
370 372 project_module_time_tracking: Time tracking
371 373 project_module_news: News
372 374 project_module_documents: Documents
373 375 project_module_files: Files
374 376 project_module_wiki: Wiki
375 377 project_module_repository: Repository
376 378 project_module_boards: Boards
377 379
378 380 label_user: User
379 381 label_user_plural: Users
380 382 label_user_new: New user
381 383 label_user_anonymous: Anonymous
382 384 label_project: Project
383 385 label_project_new: New project
384 386 label_project_plural: Projects
385 387 label_x_projects:
386 388 zero: no projects
387 389 one: 1 project
388 390 other: "{{count}} projects"
389 391 label_project_all: All Projects
390 392 label_project_latest: Latest projects
391 393 label_issue: Issue
392 394 label_issue_new: New issue
393 395 label_issue_plural: Issues
394 396 label_issue_view_all: View all issues
395 397 label_issues_by: "Issues by {{value}}"
396 398 label_issue_added: Issue added
397 399 label_issue_updated: Issue updated
398 400 label_document: Document
399 401 label_document_new: New document
400 402 label_document_plural: Documents
401 403 label_document_added: Document added
402 404 label_role: Role
403 405 label_role_plural: Roles
404 406 label_role_new: New role
405 407 label_role_and_permissions: Roles and permissions
406 408 label_member: Member
407 409 label_member_new: New member
408 410 label_member_plural: Members
409 411 label_tracker: Tracker
410 412 label_tracker_plural: Trackers
411 413 label_tracker_new: New tracker
412 414 label_workflow: Workflow
413 415 label_issue_status: Issue status
414 416 label_issue_status_plural: Issue statuses
415 417 label_issue_status_new: New status
416 418 label_issue_category: Issue category
417 419 label_issue_category_plural: Issue categories
418 420 label_issue_category_new: New category
419 421 label_custom_field: Custom field
420 422 label_custom_field_plural: Custom fields
421 423 label_custom_field_new: New custom field
422 424 label_enumerations: Enumerations
423 425 label_enumeration_new: New value
424 426 label_information: Information
425 427 label_information_plural: Information
426 428 label_please_login: Please log in
427 429 label_register: Register
428 430 label_login_with_open_id_option: or login with OpenID
429 431 label_password_lost: Lost password
430 432 label_home: Home
431 433 label_my_page: My page
432 434 label_my_account: My account
433 435 label_my_projects: My projects
434 436 label_administration: Administration
435 437 label_login: Sign in
436 438 label_logout: Sign out
437 439 label_help: Help
438 440 label_reported_issues: Reported issues
439 441 label_assigned_to_me_issues: Issues assigned to me
440 442 label_last_login: Last connection
441 443 label_registered_on: Registered on
442 444 label_activity: Activity
443 445 label_overall_activity: Overall activity
444 446 label_user_activity: "{{value}}'s activity"
445 447 label_new: New
446 448 label_logged_as: Logged in as
447 449 label_environment: Environment
448 450 label_authentication: Authentication
449 451 label_auth_source: Authentication mode
450 452 label_auth_source_new: New authentication mode
451 453 label_auth_source_plural: Authentication modes
452 454 label_subproject_plural: Subprojects
453 455 label_and_its_subprojects: "{{value}} and its subprojects"
454 456 label_min_max_length: Min - Max length
455 457 label_list: List
456 458 label_date: Date
457 459 label_integer: Integer
458 460 label_float: Float
459 461 label_boolean: Boolean
460 462 label_string: Text
461 463 label_text: Long text
462 464 label_attribute: Attribute
463 465 label_attribute_plural: Attributes
464 466 label_download: "{{count}} Download"
465 467 label_download_plural: "{{count}} Downloads"
466 468 label_no_data: No data to display
467 469 label_change_status: Change status
468 470 label_history: History
469 471 label_attachment: File
470 472 label_attachment_new: New file
471 473 label_attachment_delete: Delete file
472 474 label_attachment_plural: Files
473 475 label_file_added: File added
474 476 label_report: Report
475 477 label_report_plural: Reports
476 478 label_news: News
477 479 label_news_new: Add news
478 480 label_news_plural: News
479 481 label_news_latest: Latest news
480 482 label_news_view_all: View all news
481 483 label_news_added: News added
482 484 label_change_log: Change log
483 485 label_settings: Settings
484 486 label_overview: Overview
485 487 label_version: Version
486 488 label_version_new: New version
487 489 label_version_plural: Versions
488 490 label_confirmation: Confirmation
489 491 label_export_to: 'Also available in:'
490 492 label_read: Read...
491 493 label_public_projects: Public projects
492 494 label_open_issues: open
493 495 label_open_issues_plural: open
494 496 label_closed_issues: closed
495 497 label_closed_issues_plural: closed
496 498 label_x_open_issues_abbr_on_total:
497 499 zero: 0 open / {{total}}
498 500 one: 1 open / {{total}}
499 501 other: "{{count}} open / {{total}}"
500 502 label_x_open_issues_abbr:
501 503 zero: 0 open
502 504 one: 1 open
503 505 other: "{{count}} open"
504 506 label_x_closed_issues_abbr:
505 507 zero: 0 closed
506 508 one: 1 closed
507 509 other: "{{count}} closed"
508 510 label_total: Total
509 511 label_permissions: Permissions
510 512 label_current_status: Current status
511 513 label_new_statuses_allowed: New statuses allowed
512 514 label_all: all
513 515 label_none: none
514 516 label_nobody: nobody
515 517 label_next: Next
516 518 label_previous: Previous
517 519 label_used_by: Used by
518 520 label_details: Details
519 521 label_add_note: Add a note
520 522 label_per_page: Per page
521 523 label_calendar: Calendar
522 524 label_months_from: months from
523 525 label_gantt: Gantt
524 526 label_internal: Internal
525 527 label_last_changes: "last {{count}} changes"
526 528 label_change_view_all: View all changes
527 529 label_personalize_page: Personalize this page
528 530 label_comment: Comment
529 531 label_comment_plural: Comments
530 532 label_x_comments:
531 533 zero: no comments
532 534 one: 1 comment
533 535 other: "{{count}} comments"
534 536 label_comment_add: Add a comment
535 537 label_comment_added: Comment added
536 538 label_comment_delete: Delete comments
537 539 label_query: Custom query
538 540 label_query_plural: Custom queries
539 541 label_query_new: New query
540 542 label_filter_add: Add filter
541 543 label_filter_plural: Filters
542 544 label_equals: is
543 545 label_not_equals: is not
544 546 label_in_less_than: in less than
545 547 label_in_more_than: in more than
546 548 label_greater_or_equal: '>='
547 549 label_less_or_equal: '<='
548 550 label_in: in
549 551 label_today: today
550 552 label_all_time: all time
551 553 label_yesterday: yesterday
552 554 label_this_week: this week
553 555 label_last_week: last week
554 556 label_last_n_days: "last {{count}} days"
555 557 label_this_month: this month
556 558 label_last_month: last month
557 559 label_this_year: this year
558 560 label_date_range: Date range
559 561 label_less_than_ago: less than days ago
560 562 label_more_than_ago: more than days ago
561 563 label_ago: days ago
562 564 label_contains: contains
563 565 label_not_contains: doesn't contain
564 566 label_day_plural: days
565 567 label_repository: Repository
566 568 label_repository_plural: Repositories
567 569 label_browse: Browse
568 570 label_modification: "{{count}} change"
569 571 label_modification_plural: "{{count}} changes"
570 572 label_branch: Branch
571 573 label_tag: Tag
572 574 label_revision: Revision
573 575 label_revision_plural: Revisions
574 576 label_associated_revisions: Associated revisions
575 577 label_added: added
576 578 label_modified: modified
577 579 label_copied: copied
578 580 label_renamed: renamed
579 581 label_deleted: deleted
580 582 label_latest_revision: Latest revision
581 583 label_latest_revision_plural: Latest revisions
582 584 label_view_revisions: View revisions
583 585 label_view_all_revisions: View all revisions
584 586 label_max_size: Maximum size
585 587 label_sort_highest: Move to top
586 588 label_sort_higher: Move up
587 589 label_sort_lower: Move down
588 590 label_sort_lowest: Move to bottom
589 591 label_roadmap: Roadmap
590 592 label_roadmap_due_in: "Due in {{value}}"
591 593 label_roadmap_overdue: "{{value}} late"
592 594 label_roadmap_no_issues: No issues for this version
593 595 label_search: Search
594 596 label_result_plural: Results
595 597 label_all_words: All words
596 598 label_wiki: Wiki
597 599 label_wiki_edit: Wiki edit
598 600 label_wiki_edit_plural: Wiki edits
599 601 label_wiki_page: Wiki page
600 602 label_wiki_page_plural: Wiki pages
601 603 label_index_by_title: Index by title
602 604 label_index_by_date: Index by date
603 605 label_current_version: Current version
604 606 label_preview: Preview
605 607 label_feed_plural: Feeds
606 608 label_changes_details: Details of all changes
607 609 label_issue_tracking: Issue tracking
608 610 label_spent_time: Spent time
609 611 label_f_hour: "{{value}} hour"
610 612 label_f_hour_plural: "{{value}} hours"
611 613 label_time_tracking: Time tracking
612 614 label_change_plural: Changes
613 615 label_statistics: Statistics
614 616 label_commits_per_month: Commits per month
615 617 label_commits_per_author: Commits per author
616 618 label_view_diff: View differences
617 619 label_diff_inline: inline
618 620 label_diff_side_by_side: side by side
619 621 label_options: Options
620 622 label_copy_workflow_from: Copy workflow from
621 623 label_permissions_report: Permissions report
622 624 label_watched_issues: Watched issues
623 625 label_related_issues: Related issues
624 626 label_applied_status: Applied status
625 627 label_loading: Loading...
626 628 label_relation_new: New relation
627 629 label_relation_delete: Delete relation
628 630 label_relates_to: related to
629 631 label_duplicates: duplicates
630 632 label_duplicated_by: duplicated by
631 633 label_blocks: blocks
632 634 label_blocked_by: blocked by
633 635 label_precedes: precedes
634 636 label_follows: follows
635 637 label_end_to_start: end to start
636 638 label_end_to_end: end to end
637 639 label_start_to_start: start to start
638 640 label_start_to_end: start to end
639 641 label_stay_logged_in: Stay logged in
640 642 label_disabled: disabled
641 643 label_show_completed_versions: Show completed versions
642 644 label_me: me
643 645 label_board: Forum
644 646 label_board_new: New forum
645 647 label_board_plural: Forums
646 648 label_topic_plural: Topics
647 649 label_message_plural: Messages
648 650 label_message_last: Last message
649 651 label_message_new: New message
650 652 label_message_posted: Message added
651 653 label_reply_plural: Replies
652 654 label_send_information: Send account information to the user
653 655 label_year: Year
654 656 label_month: Month
655 657 label_week: Week
656 658 label_date_from: From
657 659 label_date_to: To
658 660 label_language_based: Based on user's language
659 661 label_sort_by: "Sort by {{value}}"
660 662 label_send_test_email: Send a test email
661 663 label_feeds_access_key_created_on: "RSS access key created {{value}} ago"
662 664 label_module_plural: Modules
663 665 label_added_time_by: "Added by {{author}} {{age}} ago"
664 666 label_updated_time_by: "Updated by {{author}} {{age}} ago"
665 667 label_updated_time: "Updated {{value}} ago"
666 668 label_jump_to_a_project: Jump to a project...
667 669 label_file_plural: Files
668 670 label_changeset_plural: Changesets
669 671 label_default_columns: Default columns
670 672 label_no_change_option: (No change)
671 673 label_bulk_edit_selected_issues: Bulk edit selected issues
672 674 label_theme: Theme
673 675 label_default: Default
674 676 label_search_titles_only: Search titles only
675 677 label_user_mail_option_all: "For any event on all my projects"
676 678 label_user_mail_option_selected: "For any event on the selected projects only..."
677 679 label_user_mail_option_none: "Only for things I watch or I'm involved in"
678 680 label_user_mail_no_self_notified: "I don't want to be notified of changes that I make myself"
679 681 label_registration_activation_by_email: account activation by email
680 682 label_registration_manual_activation: manual account activation
681 683 label_registration_automatic_activation: automatic account activation
682 684 label_display_per_page: "Per page: {{value}}"
683 685 label_age: Age
684 686 label_change_properties: Change properties
685 687 label_general: General
686 688 label_more: More
687 689 label_scm: SCM
688 690 label_plugins: Plugins
689 691 label_ldap_authentication: LDAP authentication
690 692 label_downloads_abbr: D/L
691 693 label_optional_description: Optional description
692 694 label_add_another_file: Add another file
693 695 label_preferences: Preferences
694 696 label_chronological_order: In chronological order
695 697 label_reverse_chronological_order: In reverse chronological order
696 698 label_planning: Planning
697 699 label_incoming_emails: Incoming emails
698 700 label_generate_key: Generate a key
699 701 label_issue_watchers: Watchers
700 702 label_example: Example
701 703 label_display: Display
702 704 label_sort: Sort
703 705 label_ascending: Ascending
704 706 label_descending: Descending
705 707 label_date_from_to: From {{start}} to {{end}}
706 708 label_wiki_content_added: Wiki page added
707 709 label_wiki_content_updated: Wiki page updated
708 710 label_group: Group
709 711 label_group_plural: Groups
710 712 label_group_new: New group
711 713 label_time_entry_plural: Spent time
714 label_version_sharing_none: Not shared
715 label_version_sharing_descendants: With subprojects
716 label_version_sharing_hierarchy: With project hierarchy
717 label_version_sharing_tree: With project tree
718 label_version_sharing_system: With all projects
712 719
713 720 button_login: Login
714 721 button_submit: Submit
715 722 button_save: Save
716 723 button_check_all: Check all
717 724 button_uncheck_all: Uncheck all
718 725 button_delete: Delete
719 726 button_create: Create
720 727 button_create_and_continue: Create and continue
721 728 button_test: Test
722 729 button_edit: Edit
723 730 button_add: Add
724 731 button_change: Change
725 732 button_apply: Apply
726 733 button_clear: Clear
727 734 button_lock: Lock
728 735 button_unlock: Unlock
729 736 button_download: Download
730 737 button_list: List
731 738 button_view: View
732 739 button_move: Move
733 740 button_move_and_follow: Move and follow
734 741 button_back: Back
735 742 button_cancel: Cancel
736 743 button_activate: Activate
737 744 button_sort: Sort
738 745 button_log_time: Log time
739 746 button_rollback: Rollback to this version
740 747 button_watch: Watch
741 748 button_unwatch: Unwatch
742 749 button_reply: Reply
743 750 button_archive: Archive
744 751 button_unarchive: Unarchive
745 752 button_reset: Reset
746 753 button_rename: Rename
747 754 button_change_password: Change password
748 755 button_copy: Copy
749 756 button_annotate: Annotate
750 757 button_update: Update
751 758 button_configure: Configure
752 759 button_quote: Quote
753 760
754 761 status_active: active
755 762 status_registered: registered
756 763 status_locked: locked
757 764
758 765 version_status_open: open
759 766 version_status_locked: locked
760 767 version_status_closed: closed
761 768
762 769 field_active: Active
763 770
764 771 text_select_mail_notifications: Select actions for which email notifications should be sent.
765 772 text_regexp_info: eg. ^[A-Z0-9]+$
766 773 text_min_max_length_info: 0 means no restriction
767 774 text_project_destroy_confirmation: Are you sure you want to delete this project and related data ?
768 775 text_subprojects_destroy_warning: "Its subproject(s): {{value}} will be also deleted."
769 776 text_workflow_edit: Select a role and a tracker to edit the workflow
770 777 text_are_you_sure: Are you sure ?
771 778 text_journal_changed: "{{label}} changed from {{old}} to {{new}}"
772 779 text_journal_set_to: "{{label}} set to {{value}}"
773 780 text_journal_deleted: "{{label}} deleted ({{old}})"
774 781 text_journal_added: "{{label}} {{value}} added"
775 782 text_tip_task_begin_day: task beginning this day
776 783 text_tip_task_end_day: task ending this day
777 784 text_tip_task_begin_end_day: task beginning and ending this day
778 785 text_project_identifier_info: 'Only lower case letters (a-z), numbers and dashes are allowed.<br />Once saved, the identifier can not be changed.'
779 786 text_caracters_maximum: "{{count}} characters maximum."
780 787 text_caracters_minimum: "Must be at least {{count}} characters long."
781 788 text_length_between: "Length between {{min}} and {{max}} characters."
782 789 text_tracker_no_workflow: No workflow defined for this tracker
783 790 text_unallowed_characters: Unallowed characters
784 791 text_comma_separated: Multiple values allowed (comma separated).
785 792 text_issues_ref_in_commit_messages: Referencing and fixing issues in commit messages
786 793 text_issue_added: "Issue {{id}} has been reported by {{author}}."
787 794 text_issue_updated: "Issue {{id}} has been updated by {{author}}."
788 795 text_wiki_destroy_confirmation: Are you sure you want to delete this wiki and all its content ?
789 796 text_issue_category_destroy_question: "Some issues ({{count}}) are assigned to this category. What do you want to do ?"
790 797 text_issue_category_destroy_assignments: Remove category assignments
791 798 text_issue_category_reassign_to: Reassign issues to this category
792 799 text_user_mail_option: "For unselected projects, you will only receive notifications about things you watch or you're involved in (eg. issues you're the author or assignee)."
793 800 text_no_configuration_data: "Roles, trackers, issue statuses and workflow have not been configured yet.\nIt is highly recommended to load the default configuration. You will be able to modify it once loaded."
794 801 text_load_default_configuration: Load the default configuration
795 802 text_status_changed_by_changeset: "Applied in changeset {{value}}."
796 803 text_issues_destroy_confirmation: 'Are you sure you want to delete the selected issue(s) ?'
797 804 text_select_project_modules: 'Select modules to enable for this project:'
798 805 text_default_administrator_account_changed: Default administrator account changed
799 806 text_file_repository_writable: Attachments directory writable
800 807 text_plugin_assets_writable: Plugin assets directory writable
801 808 text_rmagick_available: RMagick available (optional)
802 809 text_destroy_time_entries_question: "{{hours}} hours were reported on the issues you are about to delete. What do you want to do ?"
803 810 text_destroy_time_entries: Delete reported hours
804 811 text_assign_time_entries_to_project: Assign reported hours to the project
805 812 text_reassign_time_entries: 'Reassign reported hours to this issue:'
806 813 text_user_wrote: "{{value}} wrote:"
807 814 text_enumeration_destroy_question: "{{count}} objects are assigned to this value."
808 815 text_enumeration_category_reassign_to: 'Reassign them to this value:'
809 816 text_email_delivery_not_configured: "Email delivery is not configured, and notifications are disabled.\nConfigure your SMTP server in config/email.yml and restart the application to enable them."
810 817 text_repository_usernames_mapping: "Select or update the Redmine user mapped to each username found in the repository log.\nUsers with the same Redmine and repository username or email are automatically mapped."
811 818 text_diff_truncated: '... This diff was truncated because it exceeds the maximum size that can be displayed.'
812 819 text_custom_field_possible_values_info: 'One line for each value'
813 820 text_wiki_page_destroy_question: "This page has {{descendants}} child page(s) and descendant(s). What do you want to do?"
814 821 text_wiki_page_nullify_children: "Keep child pages as root pages"
815 822 text_wiki_page_destroy_children: "Delete child pages and all their descendants"
816 823 text_wiki_page_reassign_children: "Reassign child pages to this parent page"
817 824
818 825 default_role_manager: Manager
819 826 default_role_developper: Developer
820 827 default_role_reporter: Reporter
821 828 default_tracker_bug: Bug
822 829 default_tracker_feature: Feature
823 830 default_tracker_support: Support
824 831 default_issue_status_new: New
825 832 default_issue_status_in_progress: In Progress
826 833 default_issue_status_resolved: Resolved
827 834 default_issue_status_feedback: Feedback
828 835 default_issue_status_closed: Closed
829 836 default_issue_status_rejected: Rejected
830 837 default_doc_category_user: User documentation
831 838 default_doc_category_tech: Technical documentation
832 839 default_priority_low: Low
833 840 default_priority_normal: Normal
834 841 default_priority_high: High
835 842 default_priority_urgent: Urgent
836 843 default_priority_immediate: Immediate
837 844 default_activity_design: Design
838 845 default_activity_development: Development
839 846
840 847 enumeration_issue_priorities: Issue priorities
841 848 enumeration_doc_categories: Document categories
842 849 enumeration_activities: Activities (time tracking)
843 850 enumeration_system_activity: System Activity
@@ -1,866 +1,873
1 1 # French translations for Ruby on Rails
2 2 # by Christian Lescuyer (christian@flyingcoders.com)
3 3 # contributor: Sebastien Grosjean - ZenCocoon.com
4 4
5 5 fr:
6 6 date:
7 7 formats:
8 8 default: "%d/%m/%Y"
9 9 short: "%e %b"
10 10 long: "%e %B %Y"
11 11 long_ordinal: "%e %B %Y"
12 12 only_day: "%e"
13 13
14 14 day_names: [dimanche, lundi, mardi, mercredi, jeudi, vendredi, samedi]
15 15 abbr_day_names: [dim, lun, mar, mer, jeu, ven, sam]
16 16 month_names: [~, janvier, février, mars, avril, mai, juin, juillet, août, septembre, octobre, novembre, décembre]
17 17 abbr_month_names: [~, jan., fév., mar., avr., mai, juin, juil., août, sept., oct., nov., déc.]
18 18 order: [ :day, :month, :year ]
19 19
20 20 time:
21 21 formats:
22 22 default: "%d/%m/%Y %H:%M"
23 23 time: "%H:%M"
24 24 short: "%d %b %H:%M"
25 25 long: "%A %d %B %Y %H:%M:%S %Z"
26 26 long_ordinal: "%A %d %B %Y %H:%M:%S %Z"
27 27 only_second: "%S"
28 28 am: 'am'
29 29 pm: 'pm'
30 30
31 31 datetime:
32 32 distance_in_words:
33 33 half_a_minute: "30 secondes"
34 34 less_than_x_seconds:
35 35 zero: "moins d'une seconde"
36 36 one: "moins de 1 seconde"
37 37 other: "moins de {{count}} secondes"
38 38 x_seconds:
39 39 one: "1 seconde"
40 40 other: "{{count}} secondes"
41 41 less_than_x_minutes:
42 42 zero: "moins d'une minute"
43 43 one: "moins de 1 minute"
44 44 other: "moins de {{count}} minutes"
45 45 x_minutes:
46 46 one: "1 minute"
47 47 other: "{{count}} minutes"
48 48 about_x_hours:
49 49 one: "environ une heure"
50 50 other: "environ {{count}} heures"
51 51 x_days:
52 52 one: "1 jour"
53 53 other: "{{count}} jours"
54 54 about_x_months:
55 55 one: "environ un mois"
56 56 other: "environ {{count}} mois"
57 57 x_months:
58 58 one: "1 mois"
59 59 other: "{{count}} mois"
60 60 about_x_years:
61 61 one: "environ un an"
62 62 other: "environ {{count}} ans"
63 63 over_x_years:
64 64 one: "plus d'un an"
65 65 other: "plus de {{count}} ans"
66 66 prompts:
67 67 year: "Année"
68 68 month: "Mois"
69 69 day: "Jour"
70 70 hour: "Heure"
71 71 minute: "Minute"
72 72 second: "Seconde"
73 73
74 74 number:
75 75 format:
76 76 precision: 3
77 77 separator: ','
78 78 delimiter: ' '
79 79 currency:
80 80 format:
81 81 unit: '€'
82 82 precision: 2
83 83 format: '%n %u'
84 84 human:
85 85 format:
86 86 precision: 2
87 87 storage_units:
88 88 format: "%n %u"
89 89 units:
90 90 byte:
91 91 one: "Octet"
92 92 other: "Octet"
93 93 kb: "ko"
94 94 mb: "Mo"
95 95 gb: "Go"
96 96 tb: "To"
97 97
98 98 support:
99 99 array:
100 100 sentence_connector: 'et'
101 101 skip_last_comma: true
102 102 word_connector: ", "
103 103 two_words_connector: " et "
104 104 last_word_connector: " et "
105 105
106 106 activerecord:
107 107 errors:
108 108 template:
109 109 header:
110 110 one: "Impossible d'enregistrer {{model}}: 1 erreur"
111 111 other: "Impossible d'enregistrer {{model}}: {{count}} erreurs."
112 112 body: "Veuillez vérifier les champs suivants :"
113 113 messages:
114 114 inclusion: "n'est pas inclus(e) dans la liste"
115 115 exclusion: "n'est pas disponible"
116 116 invalid: "n'est pas valide"
117 117 confirmation: "ne concorde pas avec la confirmation"
118 118 accepted: "doit être accepté(e)"
119 119 empty: "doit être renseigné(e)"
120 120 blank: "doit être renseigné(e)"
121 121 too_long: "est trop long (pas plus de {{count}} caractères)"
122 122 too_short: "est trop court (au moins {{count}} caractères)"
123 123 wrong_length: "ne fait pas la bonne longueur (doit comporter {{count}} caractères)"
124 124 taken: "est déjà utilisé"
125 125 not_a_number: "n'est pas un nombre"
126 126 greater_than: "doit être supérieur à {{count}}"
127 127 greater_than_or_equal_to: "doit être supérieur ou égal à {{count}}"
128 128 equal_to: "doit être égal à {{count}}"
129 129 less_than: "doit être inférieur à {{count}}"
130 130 less_than_or_equal_to: "doit être inférieur ou égal à {{count}}"
131 131 odd: "doit être impair"
132 132 even: "doit être pair"
133 133 greater_than_start_date: "doit être postérieure à la date de début"
134 134 not_same_project: "n'appartient pas au même projet"
135 135 circular_dependency: "Cette relation créerait une dépendance circulaire"
136 136
137 137 actionview_instancetag_blank_option: Choisir
138 138
139 139 general_text_No: 'Non'
140 140 general_text_Yes: 'Oui'
141 141 general_text_no: 'non'
142 142 general_text_yes: 'oui'
143 143 general_lang_name: 'Français'
144 144 general_csv_separator: ';'
145 145 general_csv_decimal_separator: ','
146 146 general_csv_encoding: ISO-8859-1
147 147 general_pdf_encoding: ISO-8859-1
148 148 general_first_day_of_week: '1'
149 149
150 150 notice_account_updated: Le compte a été mis à jour avec succès.
151 151 notice_account_invalid_creditentials: Identifiant ou mot de passe invalide.
152 152 notice_account_password_updated: Mot de passe mis à jour avec succès.
153 153 notice_account_wrong_password: Mot de passe incorrect
154 154 notice_account_register_done: Un message contenant les instructions pour activer votre compte vous a été envoyé.
155 155 notice_account_unknown_email: Aucun compte ne correspond à cette adresse.
156 156 notice_can_t_change_password: Ce compte utilise une authentification externe. Impossible de changer le mot de passe.
157 157 notice_account_lost_email_sent: Un message contenant les instructions pour choisir un nouveau mot de passe vous a été envoyé.
158 158 notice_account_activated: Votre compte a été activé. Vous pouvez à présent vous connecter.
159 159 notice_successful_create: Création effectuée avec succès.
160 160 notice_successful_update: Mise à jour effectuée avec succès.
161 161 notice_successful_delete: Suppression effectuée avec succès.
162 162 notice_successful_connection: Connection réussie.
163 163 notice_file_not_found: "La page à laquelle vous souhaitez accéder n'existe pas ou a été supprimée."
164 164 notice_locking_conflict: Les données ont été mises à jour par un autre utilisateur. Mise à jour impossible.
165 165 notice_not_authorized: "Vous n'êtes pas autorisés à accéder à cette page."
166 166 notice_email_sent: "Un email a été envoyé à {{value}}"
167 167 notice_email_error: "Erreur lors de l'envoi de l'email ({{value}})"
168 168 notice_feeds_access_key_reseted: "Votre clé d'accès aux flux RSS a été réinitialisée."
169 169 notice_failed_to_save_issues: "{{count}} demande(s) sur les {{total}} sélectionnées n'ont pas pu être mise(s) à jour: {{ids}}."
170 170 notice_no_issue_selected: "Aucune demande sélectionnée ! Cochez les demandes que vous voulez mettre à jour."
171 171 notice_account_pending: "Votre compte a été créé et attend l'approbation de l'administrateur."
172 172 notice_default_data_loaded: Paramétrage par défaut chargé avec succès.
173 173 notice_unable_delete_version: Impossible de supprimer cette version.
174 174
175 175 error_can_t_load_default_data: "Une erreur s'est produite lors du chargement du paramétrage: {{value}}"
176 176 error_scm_not_found: "L'entrée et/ou la révision demandée n'existe pas dans le dépôt."
177 177 error_scm_command_failed: "Une erreur s'est produite lors de l'accès au dépôt: {{value}}"
178 178 error_scm_annotate: "L'entrée n'existe pas ou ne peut pas être annotée."
179 179 error_issue_not_found_in_project: "La demande n'existe pas ou n'appartient pas à ce projet"
180 180 error_can_not_reopen_issue_on_closed_version: 'Une demande assignée à une version fermée ne peut pas être réouverte'
181 error_can_not_archive_project: "Ce projet ne peut pas être archivé"
181 182
182 183 warning_attachments_not_saved: "{{count}} fichier(s) n'ont pas pu être sauvegardés."
183 184
184 185 mail_subject_lost_password: "Votre mot de passe {{value}}"
185 186 mail_body_lost_password: 'Pour changer votre mot de passe, cliquez sur le lien suivant:'
186 187 mail_subject_register: "Activation de votre compte {{value}}"
187 188 mail_body_register: 'Pour activer votre compte, cliquez sur le lien suivant:'
188 189 mail_body_account_information_external: "Vous pouvez utiliser votre compte {{value}} pour vous connecter."
189 190 mail_body_account_information: Paramètres de connexion de votre compte
190 191 mail_subject_account_activation_request: "Demande d'activation d'un compte {{value}}"
191 192 mail_body_account_activation_request: "Un nouvel utilisateur ({{value}}) s'est inscrit. Son compte nécessite votre approbation:"
192 193 mail_subject_reminder: "{{count}} demande(s) arrivent à échéance"
193 194 mail_body_reminder: "{{count}} demande(s) qui vous sont assignées arrivent à échéance dans les {{days}} prochains jours:"
194 195 mail_subject_wiki_content_added: "Page wiki '{{page}}' ajoutée"
195 196 mail_body_wiki_content_added: "La page wiki '{{page}}' a été ajoutée par {{author}}."
196 197 mail_subject_wiki_content_updated: "Page wiki '{{page}}' mise à jour"
197 198 mail_body_wiki_content_updated: "La page wiki '{{page}}' a été mise à jour par {{author}}."
198 199
199 200 gui_validation_error: 1 erreur
200 201 gui_validation_error_plural: "{{count}} erreurs"
201 202
202 203 field_name: Nom
203 204 field_description: Description
204 205 field_summary: Résumé
205 206 field_is_required: Obligatoire
206 207 field_firstname: Prénom
207 208 field_lastname: Nom
208 209 field_mail: Email
209 210 field_filename: Fichier
210 211 field_filesize: Taille
211 212 field_downloads: Téléchargements
212 213 field_author: Auteur
213 214 field_created_on: Créé
214 215 field_updated_on: Mis à jour
215 216 field_field_format: Format
216 217 field_is_for_all: Pour tous les projets
217 218 field_possible_values: Valeurs possibles
218 219 field_regexp: Expression régulière
219 220 field_min_length: Longueur minimum
220 221 field_max_length: Longueur maximum
221 222 field_value: Valeur
222 223 field_category: Catégorie
223 224 field_title: Titre
224 225 field_project: Projet
225 226 field_issue: Demande
226 227 field_status: Statut
227 228 field_notes: Notes
228 229 field_is_closed: Demande fermée
229 230 field_is_default: Valeur par défaut
230 231 field_tracker: Tracker
231 232 field_subject: Sujet
232 233 field_due_date: Echéance
233 234 field_assigned_to: Assigné à
234 235 field_priority: Priorité
235 236 field_fixed_version: Version cible
236 237 field_user: Utilisateur
237 238 field_role: Rôle
238 239 field_homepage: Site web
239 240 field_is_public: Public
240 241 field_parent: Sous-projet de
241 242 field_is_in_chlog: Demandes affichées dans l'historique
242 243 field_is_in_roadmap: Demandes affichées dans la roadmap
243 244 field_login: Identifiant
244 245 field_mail_notification: Notifications par mail
245 246 field_admin: Administrateur
246 247 field_last_login_on: Dernière connexion
247 248 field_language: Langue
248 249 field_effective_date: Date
249 250 field_password: Mot de passe
250 251 field_new_password: Nouveau mot de passe
251 252 field_password_confirmation: Confirmation
252 253 field_version: Version
253 254 field_type: Type
254 255 field_host: Hôte
255 256 field_port: Port
256 257 field_account: Compte
257 258 field_base_dn: Base DN
258 259 field_attr_login: Attribut Identifiant
259 260 field_attr_firstname: Attribut Prénom
260 261 field_attr_lastname: Attribut Nom
261 262 field_attr_mail: Attribut Email
262 263 field_onthefly: Création des utilisateurs à la volée
263 264 field_start_date: Début
264 265 field_done_ratio: % Réalisé
265 266 field_auth_source: Mode d'authentification
266 267 field_hide_mail: Cacher mon adresse mail
267 268 field_comments: Commentaire
268 269 field_url: URL
269 270 field_start_page: Page de démarrage
270 271 field_subproject: Sous-projet
271 272 field_hours: Heures
272 273 field_activity: Activité
273 274 field_spent_on: Date
274 275 field_identifier: Identifiant
275 276 field_is_filter: Utilisé comme filtre
276 277 field_issue_to: Demande liée
277 278 field_delay: Retard
278 279 field_assignable: Demandes assignables à ce rôle
279 280 field_redirect_existing_links: Rediriger les liens existants
280 281 field_estimated_hours: Temps estimé
281 282 field_column_names: Colonnes
282 283 field_time_zone: Fuseau horaire
283 284 field_searchable: Utilisé pour les recherches
284 285 field_default_value: Valeur par défaut
285 286 field_comments_sorting: Afficher les commentaires
286 287 field_parent_title: Page parent
287 288 field_editable: Modifiable
288 289 field_watcher: Observateur
289 290 field_identity_url: URL OpenID
290 291 field_content: Contenu
291 292 field_group_by: Grouper par
293 field_sharing: Partage
292 294
293 295 setting_app_title: Titre de l'application
294 296 setting_app_subtitle: Sous-titre de l'application
295 297 setting_welcome_text: Texte d'accueil
296 298 setting_default_language: Langue par défaut
297 299 setting_login_required: Authentification obligatoire
298 300 setting_self_registration: Inscription des nouveaux utilisateurs
299 301 setting_attachment_max_size: Taille max des fichiers
300 302 setting_issues_export_limit: Limite export demandes
301 303 setting_mail_from: Adresse d'émission
302 304 setting_bcc_recipients: Destinataires en copie cachée (cci)
303 305 setting_plain_text_mail: Mail texte brut (non HTML)
304 306 setting_host_name: Nom d'hôte et chemin
305 307 setting_text_formatting: Formatage du texte
306 308 setting_wiki_compression: Compression historique wiki
307 309 setting_feeds_limit: Limite du contenu des flux RSS
308 310 setting_default_projects_public: Définir les nouveaux projects comme publics par défaut
309 311 setting_autofetch_changesets: Récupération auto. des commits
310 312 setting_sys_api_enabled: Activer les WS pour la gestion des dépôts
311 313 setting_commit_ref_keywords: Mot-clés de référencement
312 314 setting_commit_fix_keywords: Mot-clés de résolution
313 315 setting_autologin: Autologin
314 316 setting_date_format: Format de date
315 317 setting_time_format: Format d'heure
316 318 setting_cross_project_issue_relations: Autoriser les relations entre demandes de différents projets
317 319 setting_issue_list_default_columns: Colonnes affichées par défaut sur la liste des demandes
318 320 setting_repositories_encodings: Encodages des dépôts
319 321 setting_commit_logs_encoding: Encodage des messages de commit
320 322 setting_emails_footer: Pied-de-page des emails
321 323 setting_protocol: Protocole
322 324 setting_per_page_options: Options d'objets affichés par page
323 325 setting_user_format: Format d'affichage des utilisateurs
324 326 setting_activity_days_default: Nombre de jours affichés sur l'activité des projets
325 327 setting_display_subprojects_issues: Afficher par défaut les demandes des sous-projets sur les projets principaux
326 328 setting_enabled_scm: SCM activés
327 329 setting_mail_handler_api_enabled: "Activer le WS pour la réception d'emails"
328 330 setting_mail_handler_api_key: Clé de protection de l'API
329 331 setting_sequential_project_identifiers: Générer des identifiants de projet séquentiels
330 332 setting_gravatar_enabled: Afficher les Gravatar des utilisateurs
331 333 setting_diff_max_lines_displayed: Nombre maximum de lignes de diff affichées
332 334 setting_file_max_size_displayed: Taille maximum des fichiers texte affichés en ligne
333 335 setting_repository_log_display_limit: "Nombre maximum de revisions affichées sur l'historique d'un fichier"
334 336 setting_openid: "Autoriser l'authentification et l'enregistrement OpenID"
335 337 setting_password_min_length: Longueur minimum des mots de passe
336 338 setting_new_project_user_role_id: Rôle donné à un utilisateur non-administrateur qui crée un projet
337 339 setting_default_projects_modules: Modules activés par défaut pour les nouveaux projets
338 340
339 341 permission_add_project: Créer un projet
340 342 permission_edit_project: Modifier le projet
341 343 permission_select_project_modules: Choisir les modules
342 344 permission_manage_members: Gérer les members
343 345 permission_manage_versions: Gérer les versions
344 346 permission_manage_categories: Gérer les catégories de demandes
345 347 permission_add_issues: Créer des demandes
346 348 permission_edit_issues: Modifier les demandes
347 349 permission_manage_issue_relations: Gérer les relations
348 350 permission_add_issue_notes: Ajouter des notes
349 351 permission_edit_issue_notes: Modifier les notes
350 352 permission_edit_own_issue_notes: Modifier ses propres notes
351 353 permission_move_issues: Déplacer les demandes
352 354 permission_delete_issues: Supprimer les demandes
353 355 permission_manage_public_queries: Gérer les requêtes publiques
354 356 permission_save_queries: Sauvegarder les requêtes
355 357 permission_view_gantt: Voir le gantt
356 358 permission_view_calendar: Voir le calendrier
357 359 permission_view_issue_watchers: Voir la liste des observateurs
358 360 permission_add_issue_watchers: Ajouter des observateurs
359 361 permission_delete_issue_watchers: Supprimer des observateurs
360 362 permission_log_time: Saisir le temps passé
361 363 permission_view_time_entries: Voir le temps passé
362 364 permission_edit_time_entries: Modifier les temps passés
363 365 permission_edit_own_time_entries: Modifier son propre temps passé
364 366 permission_manage_news: Gérer les annonces
365 367 permission_comment_news: Commenter les annonces
366 368 permission_manage_documents: Gérer les documents
367 369 permission_view_documents: Voir les documents
368 370 permission_manage_files: Gérer les fichiers
369 371 permission_view_files: Voir les fichiers
370 372 permission_manage_wiki: Gérer le wiki
371 373 permission_rename_wiki_pages: Renommer les pages
372 374 permission_delete_wiki_pages: Supprimer les pages
373 375 permission_view_wiki_pages: Voir le wiki
374 376 permission_view_wiki_edits: "Voir l'historique des modifications"
375 377 permission_edit_wiki_pages: Modifier les pages
376 378 permission_delete_wiki_pages_attachments: Supprimer les fichiers joints
377 379 permission_protect_wiki_pages: Protéger les pages
378 380 permission_manage_repository: Gérer le dépôt de sources
379 381 permission_browse_repository: Parcourir les sources
380 382 permission_view_changesets: Voir les révisions
381 383 permission_commit_access: Droit de commit
382 384 permission_manage_boards: Gérer les forums
383 385 permission_view_messages: Voir les messages
384 386 permission_add_messages: Poster un message
385 387 permission_edit_messages: Modifier les messages
386 388 permission_edit_own_messages: Modifier ses propres messages
387 389 permission_delete_messages: Supprimer les messages
388 390 permission_delete_own_messages: Supprimer ses propres messages
389 391
390 392 project_module_issue_tracking: Suivi des demandes
391 393 project_module_time_tracking: Suivi du temps passé
392 394 project_module_news: Publication d'annonces
393 395 project_module_documents: Publication de documents
394 396 project_module_files: Publication de fichiers
395 397 project_module_wiki: Wiki
396 398 project_module_repository: Dépôt de sources
397 399 project_module_boards: Forums de discussion
398 400
399 401 label_user: Utilisateur
400 402 label_user_plural: Utilisateurs
401 403 label_user_new: Nouvel utilisateur
402 404 label_user_anonymous: Anonyme
403 405 label_project: Projet
404 406 label_project_new: Nouveau projet
405 407 label_project_plural: Projets
406 408 label_x_projects:
407 409 zero: aucun projet
408 410 one: 1 projet
409 411 other: "{{count}} projets"
410 412 label_project_all: Tous les projets
411 413 label_project_latest: Derniers projets
412 414 label_issue: Demande
413 415 label_issue_new: Nouvelle demande
414 416 label_issue_plural: Demandes
415 417 label_issue_view_all: Voir toutes les demandes
416 418 label_issue_added: Demande ajoutée
417 419 label_issue_updated: Demande mise à jour
418 420 label_issues_by: "Demandes par {{value}}"
419 421 label_document: Document
420 422 label_document_new: Nouveau document
421 423 label_document_plural: Documents
422 424 label_document_added: Document ajouté
423 425 label_role: Rôle
424 426 label_role_plural: Rôles
425 427 label_role_new: Nouveau rôle
426 428 label_role_and_permissions: Rôles et permissions
427 429 label_member: Membre
428 430 label_member_new: Nouveau membre
429 431 label_member_plural: Membres
430 432 label_tracker: Tracker
431 433 label_tracker_plural: Trackers
432 434 label_tracker_new: Nouveau tracker
433 435 label_workflow: Workflow
434 436 label_issue_status: Statut de demandes
435 437 label_issue_status_plural: Statuts de demandes
436 438 label_issue_status_new: Nouveau statut
437 439 label_issue_category: Catégorie de demandes
438 440 label_issue_category_plural: Catégories de demandes
439 441 label_issue_category_new: Nouvelle catégorie
440 442 label_custom_field: Champ personnalisé
441 443 label_custom_field_plural: Champs personnalisés
442 444 label_custom_field_new: Nouveau champ personnalisé
443 445 label_enumerations: Listes de valeurs
444 446 label_enumeration_new: Nouvelle valeur
445 447 label_information: Information
446 448 label_information_plural: Informations
447 449 label_please_login: Identification
448 450 label_register: S'enregistrer
449 451 label_login_with_open_id_option: S'authentifier avec OpenID
450 452 label_password_lost: Mot de passe perdu
451 453 label_home: Accueil
452 454 label_my_page: Ma page
453 455 label_my_account: Mon compte
454 456 label_my_projects: Mes projets
455 457 label_administration: Administration
456 458 label_login: Connexion
457 459 label_logout: Déconnexion
458 460 label_help: Aide
459 461 label_reported_issues: Demandes soumises
460 462 label_assigned_to_me_issues: Demandes qui me sont assignées
461 463 label_last_login: Dernière connexion
462 464 label_registered_on: Inscrit le
463 465 label_activity: Activité
464 466 label_overall_activity: Activité globale
465 467 label_user_activity: "Activité de {{value}}"
466 468 label_new: Nouveau
467 469 label_logged_as: Connecté en tant que
468 470 label_environment: Environnement
469 471 label_authentication: Authentification
470 472 label_auth_source: Mode d'authentification
471 473 label_auth_source_new: Nouveau mode d'authentification
472 474 label_auth_source_plural: Modes d'authentification
473 475 label_subproject_plural: Sous-projets
474 476 label_and_its_subprojects: "{{value}} et ses sous-projets"
475 477 label_min_max_length: Longueurs mini - maxi
476 478 label_list: Liste
477 479 label_date: Date
478 480 label_integer: Entier
479 481 label_float: Nombre décimal
480 482 label_boolean: Booléen
481 483 label_string: Texte
482 484 label_text: Texte long
483 485 label_attribute: Attribut
484 486 label_attribute_plural: Attributs
485 487 label_download: "{{count}} Téléchargement"
486 488 label_download_plural: "{{count}} Téléchargements"
487 489 label_no_data: Aucune donnée à afficher
488 490 label_change_status: Changer le statut
489 491 label_history: Historique
490 492 label_attachment: Fichier
491 493 label_attachment_new: Nouveau fichier
492 494 label_attachment_delete: Supprimer le fichier
493 495 label_attachment_plural: Fichiers
494 496 label_file_added: Fichier ajouté
495 497 label_report: Rapport
496 498 label_report_plural: Rapports
497 499 label_news: Annonce
498 500 label_news_new: Nouvelle annonce
499 501 label_news_plural: Annonces
500 502 label_news_latest: Dernières annonces
501 503 label_news_view_all: Voir toutes les annonces
502 504 label_news_added: Annonce ajoutée
503 505 label_change_log: Historique
504 506 label_settings: Configuration
505 507 label_overview: Aperçu
506 508 label_version: Version
507 509 label_version_new: Nouvelle version
508 510 label_version_plural: Versions
509 511 label_confirmation: Confirmation
510 512 label_export_to: 'Formats disponibles:'
511 513 label_read: Lire...
512 514 label_public_projects: Projets publics
513 515 label_open_issues: ouvert
514 516 label_open_issues_plural: ouverts
515 517 label_closed_issues: fermé
516 518 label_closed_issues_plural: fermés
517 519 label_x_open_issues_abbr_on_total:
518 520 zero: 0 ouvert sur {{total}}
519 521 one: 1 ouvert sur {{total}}
520 522 other: "{{count}} ouverts sur {{total}}"
521 523 label_x_open_issues_abbr:
522 524 zero: 0 ouvert
523 525 one: 1 ouvert
524 526 other: "{{count}} ouverts"
525 527 label_x_closed_issues_abbr:
526 528 zero: 0 fermé
527 529 one: 1 fermé
528 530 other: "{{count}} fermés"
529 531 label_total: Total
530 532 label_permissions: Permissions
531 533 label_current_status: Statut actuel
532 534 label_new_statuses_allowed: Nouveaux statuts autorisés
533 535 label_all: tous
534 536 label_none: aucun
535 537 label_nobody: personne
536 538 label_next: Suivant
537 539 label_previous: Précédent
538 540 label_used_by: Utilisé par
539 541 label_details: Détails
540 542 label_add_note: Ajouter une note
541 543 label_per_page: Par page
542 544 label_calendar: Calendrier
543 545 label_months_from: mois depuis
544 546 label_gantt: Gantt
545 547 label_internal: Interne
546 548 label_last_changes: "{{count}} derniers changements"
547 549 label_change_view_all: Voir tous les changements
548 550 label_personalize_page: Personnaliser cette page
549 551 label_comment: Commentaire
550 552 label_comment_plural: Commentaires
551 553 label_x_comments:
552 554 zero: aucun commentaire
553 555 one: 1 commentaire
554 556 other: "{{count}} commentaires"
555 557 label_comment_add: Ajouter un commentaire
556 558 label_comment_added: Commentaire ajouté
557 559 label_comment_delete: Supprimer les commentaires
558 560 label_query: Rapport personnalisé
559 561 label_query_plural: Rapports personnalisés
560 562 label_query_new: Nouveau rapport
561 563 label_filter_add: Ajouter le filtre
562 564 label_filter_plural: Filtres
563 565 label_equals: égal
564 566 label_not_equals: différent
565 567 label_in_less_than: dans moins de
566 568 label_in_more_than: dans plus de
567 569 label_in: dans
568 570 label_today: aujourd'hui
569 571 label_all_time: toute la période
570 572 label_yesterday: hier
571 573 label_this_week: cette semaine
572 574 label_last_week: la semaine dernière
573 575 label_last_n_days: "les {{count}} derniers jours"
574 576 label_this_month: ce mois-ci
575 577 label_last_month: le mois dernier
576 578 label_this_year: cette année
577 579 label_date_range: Période
578 580 label_less_than_ago: il y a moins de
579 581 label_more_than_ago: il y a plus de
580 582 label_ago: il y a
581 583 label_contains: contient
582 584 label_not_contains: ne contient pas
583 585 label_day_plural: jours
584 586 label_repository: Dépôt
585 587 label_repository_plural: Dépôts
586 588 label_browse: Parcourir
587 589 label_modification: "{{count}} modification"
588 590 label_modification_plural: "{{count}} modifications"
589 591 label_revision: Révision
590 592 label_revision_plural: Révisions
591 593 label_associated_revisions: Révisions associées
592 594 label_added: ajouté
593 595 label_modified: modifié
594 596 label_copied: copié
595 597 label_renamed: renommé
596 598 label_deleted: supprimé
597 599 label_latest_revision: Dernière révision
598 600 label_latest_revision_plural: Dernières révisions
599 601 label_view_revisions: Voir les révisions
600 602 label_max_size: Taille maximale
601 603 label_sort_highest: Remonter en premier
602 604 label_sort_higher: Remonter
603 605 label_sort_lower: Descendre
604 606 label_sort_lowest: Descendre en dernier
605 607 label_roadmap: Roadmap
606 608 label_roadmap_due_in: "Echéance dans {{value}}"
607 609 label_roadmap_overdue: "En retard de {{value}}"
608 610 label_roadmap_no_issues: Aucune demande pour cette version
609 611 label_search: Recherche
610 612 label_result_plural: Résultats
611 613 label_all_words: Tous les mots
612 614 label_wiki: Wiki
613 615 label_wiki_edit: Révision wiki
614 616 label_wiki_edit_plural: Révisions wiki
615 617 label_wiki_page: Page wiki
616 618 label_wiki_page_plural: Pages wiki
617 619 label_index_by_title: Index par titre
618 620 label_index_by_date: Index par date
619 621 label_current_version: Version actuelle
620 622 label_preview: Prévisualisation
621 623 label_feed_plural: Flux RSS
622 624 label_changes_details: Détails de tous les changements
623 625 label_issue_tracking: Suivi des demandes
624 626 label_spent_time: Temps passé
625 627 label_f_hour: "{{value}} heure"
626 628 label_f_hour_plural: "{{value}} heures"
627 629 label_time_tracking: Suivi du temps
628 630 label_change_plural: Changements
629 631 label_statistics: Statistiques
630 632 label_commits_per_month: Commits par mois
631 633 label_commits_per_author: Commits par auteur
632 634 label_view_diff: Voir les différences
633 635 label_diff_inline: en ligne
634 636 label_diff_side_by_side: côte à côte
635 637 label_options: Options
636 638 label_copy_workflow_from: Copier le workflow de
637 639 label_permissions_report: Synthèse des permissions
638 640 label_watched_issues: Demandes surveillées
639 641 label_related_issues: Demandes liées
640 642 label_applied_status: Statut appliqué
641 643 label_loading: Chargement...
642 644 label_relation_new: Nouvelle relation
643 645 label_relation_delete: Supprimer la relation
644 646 label_relates_to: lié à
645 647 label_duplicates: duplique
646 648 label_duplicated_by: dupliqué par
647 649 label_blocks: bloque
648 650 label_blocked_by: bloqué par
649 651 label_precedes: précède
650 652 label_follows: suit
651 653 label_end_to_start: fin à début
652 654 label_end_to_end: fin à fin
653 655 label_start_to_start: début à début
654 656 label_start_to_end: début à fin
655 657 label_stay_logged_in: Rester connecté
656 658 label_disabled: désactivé
657 659 label_show_completed_versions: Voir les versions passées
658 660 label_me: moi
659 661 label_board: Forum
660 662 label_board_new: Nouveau forum
661 663 label_board_plural: Forums
662 664 label_topic_plural: Discussions
663 665 label_message_plural: Messages
664 666 label_message_last: Dernier message
665 667 label_message_new: Nouveau message
666 668 label_message_posted: Message ajouté
667 669 label_reply_plural: Réponses
668 670 label_send_information: Envoyer les informations à l'utilisateur
669 671 label_year: Année
670 672 label_month: Mois
671 673 label_week: Semaine
672 674 label_date_from: Du
673 675 label_date_to: Au
674 676 label_language_based: Basé sur la langue de l'utilisateur
675 677 label_sort_by: "Trier par {{value}}"
676 678 label_send_test_email: Envoyer un email de test
677 679 label_feeds_access_key_created_on: "Clé d'accès RSS créée il y a {{value}}"
678 680 label_module_plural: Modules
679 681 label_added_time_by: "Ajouté par {{author}} il y a {{age}}"
680 682 label_updated_time_by: "Mis à jour par {{author}} il y a {{age}}"
681 683 label_updated_time: "Mis à jour il y a {{value}}"
682 684 label_jump_to_a_project: Aller à un projet...
683 685 label_file_plural: Fichiers
684 686 label_changeset_plural: Révisions
685 687 label_default_columns: Colonnes par défaut
686 688 label_no_change_option: (Pas de changement)
687 689 label_bulk_edit_selected_issues: Modifier les demandes sélectionnées
688 690 label_theme: Thème
689 691 label_default: Défaut
690 692 label_search_titles_only: Uniquement dans les titres
691 693 label_user_mail_option_all: "Pour tous les événements de tous mes projets"
692 694 label_user_mail_option_selected: "Pour tous les événements des projets sélectionnés..."
693 695 label_user_mail_option_none: "Seulement pour ce que je surveille ou à quoi je participe"
694 696 label_user_mail_no_self_notified: "Je ne veux pas être notifié des changements que j'effectue"
695 697 label_registration_activation_by_email: activation du compte par email
696 698 label_registration_manual_activation: activation manuelle du compte
697 699 label_registration_automatic_activation: activation automatique du compte
698 700 label_display_per_page: "Par page: {{value}}"
699 701 label_age: Age
700 702 label_change_properties: Changer les propriétés
701 703 label_general: Général
702 704 label_more: Plus
703 705 label_scm: SCM
704 706 label_plugins: Plugins
705 707 label_ldap_authentication: Authentification LDAP
706 708 label_downloads_abbr: D/L
707 709 label_optional_description: Description facultative
708 710 label_add_another_file: Ajouter un autre fichier
709 711 label_preferences: Préférences
710 712 label_chronological_order: Dans l'ordre chronologique
711 713 label_reverse_chronological_order: Dans l'ordre chronologique inverse
712 714 label_planning: Planning
713 715 label_incoming_emails: Emails entrants
714 716 label_generate_key: Générer une clé
715 717 label_issue_watchers: Observateurs
716 718 label_example: Exemple
717 719 label_display: Affichage
718 720 label_sort: Tri
719 721 label_ascending: Croissant
720 722 label_descending: Décroissant
721 723 label_date_from_to: Du {{start}} au {{end}}
722 724 label_wiki_content_added: Page wiki ajoutée
723 725 label_wiki_content_updated: Page wiki mise à jour
724 726 label_group_plural: Groupes
725 727 label_group: Groupe
726 728 label_group_new: Nouveau groupe
727 729 label_time_entry_plural: Temps passé
730 label_version_sharing_none: Non partagé
731 label_version_sharing_descendants: Avec les sous-projets
732 label_version_sharing_hierarchy: Avec toute la hiérarchie
733 label_version_sharing_tree: Avec tout l'arbre
734 label_version_sharing_system: Avec tous les projets
728 735
729 736 button_login: Connexion
730 737 button_submit: Soumettre
731 738 button_save: Sauvegarder
732 739 button_check_all: Tout cocher
733 740 button_uncheck_all: Tout décocher
734 741 button_delete: Supprimer
735 742 button_create: Créer
736 743 button_create_and_continue: Créer et continuer
737 744 button_test: Tester
738 745 button_edit: Modifier
739 746 button_add: Ajouter
740 747 button_change: Changer
741 748 button_apply: Appliquer
742 749 button_clear: Effacer
743 750 button_lock: Verrouiller
744 751 button_unlock: Déverrouiller
745 752 button_download: Télécharger
746 753 button_list: Lister
747 754 button_view: Voir
748 755 button_move: Déplacer
749 756 button_move_and_follow: Déplacer et suivre
750 757 button_back: Retour
751 758 button_cancel: Annuler
752 759 button_activate: Activer
753 760 button_sort: Trier
754 761 button_log_time: Saisir temps
755 762 button_rollback: Revenir à cette version
756 763 button_watch: Surveiller
757 764 button_unwatch: Ne plus surveiller
758 765 button_reply: Répondre
759 766 button_archive: Archiver
760 767 button_unarchive: Désarchiver
761 768 button_reset: Réinitialiser
762 769 button_rename: Renommer
763 770 button_change_password: Changer de mot de passe
764 771 button_copy: Copier
765 772 button_annotate: Annoter
766 773 button_update: Mettre à jour
767 774 button_configure: Configurer
768 775 button_quote: Citer
769 776
770 777 status_active: actif
771 778 status_registered: enregistré
772 779 status_locked: vérouillé
773 780
774 781 version_status_open: ouvert
775 782 version_status_locked: vérouillé
776 783 version_status_closed: fermé
777 784
778 785 text_select_mail_notifications: Actions pour lesquelles une notification par e-mail est envoyée
779 786 text_regexp_info: ex. ^[A-Z0-9]+$
780 787 text_min_max_length_info: 0 pour aucune restriction
781 788 text_project_destroy_confirmation: Etes-vous sûr de vouloir supprimer ce projet et toutes ses données ?
782 789 text_subprojects_destroy_warning: "Ses sous-projets: {{value}} seront également supprimés."
783 790 text_workflow_edit: Sélectionner un tracker et un rôle pour éditer le workflow
784 791 text_are_you_sure: Etes-vous sûr ?
785 792 text_tip_task_begin_day: tâche commençant ce jour
786 793 text_tip_task_end_day: tâche finissant ce jour
787 794 text_tip_task_begin_end_day: tâche commençant et finissant ce jour
788 795 text_project_identifier_info: 'Seuls les lettres minuscules (a-z), chiffres et tirets sont autorisés.<br />Un fois sauvegardé, l''identifiant ne pourra plus être modifié.'
789 796 text_caracters_maximum: "{{count}} caractères maximum."
790 797 text_caracters_minimum: "{{count}} caractères minimum."
791 798 text_length_between: "Longueur comprise entre {{min}} et {{max}} caractères."
792 799 text_tracker_no_workflow: Aucun worflow n'est défini pour ce tracker
793 800 text_unallowed_characters: Caractères non autorisés
794 801 text_comma_separated: Plusieurs valeurs possibles (séparées par des virgules).
795 802 text_issues_ref_in_commit_messages: Référencement et résolution des demandes dans les commentaires de commits
796 803 text_issue_added: "La demande {{id}} a été soumise par {{author}}."
797 804 text_issue_updated: "La demande {{id}} a été mise à jour par {{author}}."
798 805 text_wiki_destroy_confirmation: Etes-vous sûr de vouloir supprimer ce wiki et tout son contenu ?
799 806 text_issue_category_destroy_question: "{{count}} demandes sont affectées à cette catégories. Que voulez-vous faire ?"
800 807 text_issue_category_destroy_assignments: N'affecter les demandes à aucune autre catégorie
801 808 text_issue_category_reassign_to: Réaffecter les demandes à cette catégorie
802 809 text_user_mail_option: "Pour les projets non sélectionnés, vous recevrez seulement des notifications pour ce que vous surveillez ou à quoi vous participez (exemple: demandes dont vous êtes l'auteur ou la personne assignée)."
803 810 text_no_configuration_data: "Les rôles, trackers, statuts et le workflow ne sont pas encore paramétrés.\nIl est vivement recommandé de charger le paramétrage par defaut. Vous pourrez le modifier une fois chargé."
804 811 text_load_default_configuration: Charger le paramétrage par défaut
805 812 text_status_changed_by_changeset: "Appliqué par commit {{value}}."
806 813 text_issues_destroy_confirmation: 'Etes-vous sûr de vouloir supprimer le(s) demandes(s) selectionnée(s) ?'
807 814 text_select_project_modules: 'Selectionner les modules à activer pour ce project:'
808 815 text_default_administrator_account_changed: Compte administrateur par défaut changé
809 816 text_file_repository_writable: Répertoire de stockage des fichiers accessible en écriture
810 817 text_plugin_assets_writable: Répertoire public des plugins accessible en écriture
811 818 text_rmagick_available: Bibliothèque RMagick présente (optionnelle)
812 819 text_destroy_time_entries_question: "{{hours}} heures ont été enregistrées sur les demandes à supprimer. Que voulez-vous faire ?"
813 820 text_destroy_time_entries: Supprimer les heures
814 821 text_assign_time_entries_to_project: Reporter les heures sur le projet
815 822 text_reassign_time_entries: 'Reporter les heures sur cette demande:'
816 823 text_user_wrote: "{{value}} a écrit:"
817 824 text_enumeration_destroy_question: "Cette valeur est affectée à {{count}} objets."
818 825 text_enumeration_category_reassign_to: 'Réaffecter les objets à cette valeur:'
819 826 text_email_delivery_not_configured: "L'envoi de mail n'est pas configuré, les notifications sont désactivées.\nConfigurez votre serveur SMTP dans config/email.yml et redémarrez l'application pour les activer."
820 827 text_repository_usernames_mapping: "Vous pouvez sélectionner ou modifier l'utilisateur Redmine associé à chaque nom d'utilisateur figurant dans l'historique du dépôt.\nLes utilisateurs avec le même identifiant ou la même adresse mail seront automatiquement associés."
821 828 text_diff_truncated: '... Ce différentiel a été tronqué car il excède la taille maximale pouvant être affichée.'
822 829 text_custom_field_possible_values_info: 'Une ligne par valeur'
823 830 text_wiki_page_destroy_question: "Cette page possède {{descendants}} sous-page(s) et descendante(s). Que voulez-vous faire ?"
824 831 text_wiki_page_nullify_children: "Conserver les sous-pages en tant que pages racines"
825 832 text_wiki_page_destroy_children: "Supprimer les sous-pages et toutes leurs descedantes"
826 833 text_wiki_page_reassign_children: "Réaffecter les sous-pages à cette page"
827 834
828 835 default_role_manager: Manager
829 836 default_role_developper: Développeur
830 837 default_role_reporter: Rapporteur
831 838 default_tracker_bug: Anomalie
832 839 default_tracker_feature: Evolution
833 840 default_tracker_support: Assistance
834 841 default_issue_status_new: Nouveau
835 842 default_issue_status_in_progress: In Progress
836 843 default_issue_status_resolved: Résolu
837 844 default_issue_status_feedback: Commentaire
838 845 default_issue_status_closed: Fermé
839 846 default_issue_status_rejected: Rejeté
840 847 default_doc_category_user: Documentation utilisateur
841 848 default_doc_category_tech: Documentation technique
842 849 default_priority_low: Bas
843 850 default_priority_normal: Normal
844 851 default_priority_high: Haut
845 852 default_priority_urgent: Urgent
846 853 default_priority_immediate: Immédiat
847 854 default_activity_design: Conception
848 855 default_activity_development: Développement
849 856
850 857 enumeration_issue_priorities: Priorités des demandes
851 858 enumeration_doc_categories: Catégories des documents
852 859 enumeration_activities: Activités (suivi du temps)
853 860 label_greater_or_equal: ">="
854 861 label_less_or_equal: "<="
855 862 label_view_all_revisions: Voir toutes les révisions
856 863 label_tag: Tag
857 864 label_branch: Branche
858 865 error_no_tracker_in_project: "Aucun tracker n'est associé à ce projet. Vérifier la configuration du projet."
859 866 error_no_default_issue_status: "Aucun statut de demande n'est défini par défaut. Vérifier votre configuration (Administration -> Statuts de demandes)."
860 867 text_journal_changed: "{{label}} changé de {{old}} à {{new}}"
861 868 text_journal_set_to: "{{label}} mis à {{value}}"
862 869 text_journal_deleted: "{{label}} {{old}} supprimé"
863 870 text_journal_added: "{{label}} {{value}} ajouté"
864 871 field_active: Actif
865 872 enumeration_system_activity: Activité système
866 873 setting_gravatar_default: Default Gravatar image
@@ -1,174 +1,174
1 1 require 'redmine/access_control'
2 2 require 'redmine/menu_manager'
3 3 require 'redmine/activity'
4 4 require 'redmine/mime_type'
5 5 require 'redmine/core_ext'
6 6 require 'redmine/themes'
7 7 require 'redmine/hook'
8 8 require 'redmine/plugin'
9 9 require 'redmine/wiki_formatting'
10 10
11 11 begin
12 12 require_library_or_gem 'RMagick' unless Object.const_defined?(:Magick)
13 13 rescue LoadError
14 14 # RMagick is not available
15 15 end
16 16
17 17 if RUBY_VERSION < '1.9'
18 18 require 'faster_csv'
19 19 else
20 20 require 'csv'
21 21 FCSV = CSV
22 22 end
23 23
24 24 REDMINE_SUPPORTED_SCM = %w( Subversion Darcs Mercurial Cvs Bazaar Git Filesystem )
25 25
26 26 # Permissions
27 27 Redmine::AccessControl.map do |map|
28 28 map.permission :view_project, {:projects => [:show, :activity]}, :public => true
29 29 map.permission :search_project, {:search => :index}, :public => true
30 30 map.permission :add_project, {:projects => :add}, :require => :loggedin
31 31 map.permission :edit_project, {:projects => [:settings, :edit]}, :require => :member
32 32 map.permission :select_project_modules, {:projects => :modules}, :require => :member
33 33 map.permission :manage_members, {:projects => :settings, :members => [:new, :edit, :destroy, :autocomplete_for_member]}, :require => :member
34 34 map.permission :manage_versions, {:projects => [:settings, :add_version], :versions => [:edit, :close_completed, :destroy]}, :require => :member
35 35
36 36 map.project_module :issue_tracking do |map|
37 37 # Issue categories
38 38 map.permission :manage_categories, {:projects => [:settings, :add_issue_category], :issue_categories => [:edit, :destroy]}, :require => :member
39 39 # Issues
40 40 map.permission :view_issues, {:projects => [:changelog, :roadmap],
41 41 :issues => [:index, :changes, :show, :context_menu],
42 42 :versions => [:show, :status_by],
43 43 :queries => :index,
44 44 :reports => :issue_report}
45 45 map.permission :add_issues, {:issues => [:new, :update_form]}
46 46 map.permission :edit_issues, {:issues => [:edit, :reply, :bulk_edit, :update_form]}
47 47 map.permission :manage_issue_relations, {:issue_relations => [:new, :destroy]}
48 48 map.permission :add_issue_notes, {:issues => [:edit, :reply]}
49 49 map.permission :edit_issue_notes, {:journals => :edit}, :require => :loggedin
50 50 map.permission :edit_own_issue_notes, {:journals => :edit}, :require => :loggedin
51 51 map.permission :move_issues, {:issues => :move}, :require => :loggedin
52 52 map.permission :delete_issues, {:issues => :destroy}, :require => :member
53 53 # Queries
54 54 map.permission :manage_public_queries, {:queries => [:new, :edit, :destroy]}, :require => :member
55 55 map.permission :save_queries, {:queries => [:new, :edit, :destroy]}, :require => :loggedin
56 56 # Gantt & calendar
57 57 map.permission :view_gantt, :issues => :gantt
58 58 map.permission :view_calendar, :issues => :calendar
59 59 # Watchers
60 60 map.permission :view_issue_watchers, {}
61 61 map.permission :add_issue_watchers, {:watchers => :new}
62 62 map.permission :delete_issue_watchers, {:watchers => :destroy}
63 63 end
64 64
65 65 map.project_module :time_tracking do |map|
66 66 map.permission :log_time, {:timelog => :edit}, :require => :loggedin
67 67 map.permission :view_time_entries, :timelog => [:details, :report]
68 68 map.permission :edit_time_entries, {:timelog => [:edit, :destroy]}, :require => :member
69 69 map.permission :edit_own_time_entries, {:timelog => [:edit, :destroy]}, :require => :loggedin
70 70 map.permission :manage_project_activities, {:projects => [:save_activities, :reset_activities]}, :require => :member
71 71 end
72 72
73 73 map.project_module :news do |map|
74 74 map.permission :manage_news, {:news => [:new, :edit, :destroy, :destroy_comment]}, :require => :member
75 75 map.permission :view_news, {:news => [:index, :show]}, :public => true
76 76 map.permission :comment_news, {:news => :add_comment}
77 77 end
78 78
79 79 map.project_module :documents do |map|
80 80 map.permission :manage_documents, {:documents => [:new, :edit, :destroy, :add_attachment]}, :require => :loggedin
81 81 map.permission :view_documents, :documents => [:index, :show, :download]
82 82 end
83 83
84 84 map.project_module :files do |map|
85 85 map.permission :manage_files, {:projects => :add_file}, :require => :loggedin
86 86 map.permission :view_files, :projects => :list_files, :versions => :download
87 87 end
88 88
89 89 map.project_module :wiki do |map|
90 90 map.permission :manage_wiki, {:wikis => [:edit, :destroy]}, :require => :member
91 91 map.permission :rename_wiki_pages, {:wiki => :rename}, :require => :member
92 92 map.permission :delete_wiki_pages, {:wiki => :destroy}, :require => :member
93 93 map.permission :view_wiki_pages, :wiki => [:index, :special]
94 94 map.permission :view_wiki_edits, :wiki => [:history, :diff, :annotate]
95 95 map.permission :edit_wiki_pages, :wiki => [:edit, :preview, :add_attachment]
96 96 map.permission :delete_wiki_pages_attachments, {}
97 97 map.permission :protect_wiki_pages, {:wiki => :protect}, :require => :member
98 98 end
99 99
100 100 map.project_module :repository do |map|
101 101 map.permission :manage_repository, {:repositories => [:edit, :committers, :destroy]}, :require => :member
102 102 map.permission :browse_repository, :repositories => [:show, :browse, :entry, :annotate, :changes, :diff, :stats, :graph]
103 103 map.permission :view_changesets, :repositories => [:show, :revisions, :revision]
104 104 map.permission :commit_access, {}
105 105 end
106 106
107 107 map.project_module :boards do |map|
108 108 map.permission :manage_boards, {:boards => [:new, :edit, :destroy]}, :require => :member
109 109 map.permission :view_messages, {:boards => [:index, :show], :messages => [:show]}, :public => true
110 110 map.permission :add_messages, {:messages => [:new, :reply, :quote]}
111 111 map.permission :edit_messages, {:messages => :edit}, :require => :member
112 112 map.permission :edit_own_messages, {:messages => :edit}, :require => :loggedin
113 113 map.permission :delete_messages, {:messages => :destroy}, :require => :member
114 114 map.permission :delete_own_messages, {:messages => :destroy}, :require => :loggedin
115 115 end
116 116 end
117 117
118 118 Redmine::MenuManager.map :top_menu do |menu|
119 119 menu.push :home, :home_path
120 120 menu.push :my_page, { :controller => 'my', :action => 'page' }, :if => Proc.new { User.current.logged? }
121 121 menu.push :projects, { :controller => 'projects', :action => 'index' }, :caption => :label_project_plural
122 122 menu.push :administration, { :controller => 'admin', :action => 'index' }, :if => Proc.new { User.current.admin? }, :last => true
123 123 menu.push :help, Redmine::Info.help_url, :last => true
124 124 end
125 125
126 126 Redmine::MenuManager.map :account_menu do |menu|
127 127 menu.push :login, :signin_path, :if => Proc.new { !User.current.logged? }
128 128 menu.push :register, { :controller => 'account', :action => 'register' }, :if => Proc.new { !User.current.logged? && Setting.self_registration? }
129 129 menu.push :my_account, { :controller => 'my', :action => 'account' }, :if => Proc.new { User.current.logged? }
130 130 menu.push :logout, :signout_path, :if => Proc.new { User.current.logged? }
131 131 end
132 132
133 133 Redmine::MenuManager.map :application_menu do |menu|
134 134 # Empty
135 135 end
136 136
137 137 Redmine::MenuManager.map :admin_menu do |menu|
138 138 # Empty
139 139 end
140 140
141 141 Redmine::MenuManager.map :project_menu do |menu|
142 142 menu.push :overview, { :controller => 'projects', :action => 'show' }
143 143 menu.push :activity, { :controller => 'projects', :action => 'activity' }
144 144 menu.push :roadmap, { :controller => 'projects', :action => 'roadmap' },
145 :if => Proc.new { |p| p.versions.any? }
145 :if => Proc.new { |p| p.shared_versions.any? }
146 146 menu.push :issues, { :controller => 'issues', :action => 'index' }, :param => :project_id, :caption => :label_issue_plural
147 147 menu.push :new_issue, { :controller => 'issues', :action => 'new' }, :param => :project_id, :caption => :label_issue_new,
148 148 :html => { :accesskey => Redmine::AccessKeys.key_for(:new_issue) }
149 149 menu.push :news, { :controller => 'news', :action => 'index' }, :param => :project_id, :caption => :label_news_plural
150 150 menu.push :documents, { :controller => 'documents', :action => 'index' }, :param => :project_id, :caption => :label_document_plural
151 151 menu.push :wiki, { :controller => 'wiki', :action => 'index', :page => nil },
152 152 :if => Proc.new { |p| p.wiki && !p.wiki.new_record? }
153 153 menu.push :boards, { :controller => 'boards', :action => 'index', :id => nil }, :param => :project_id,
154 154 :if => Proc.new { |p| p.boards.any? }, :caption => :label_board_plural
155 155 menu.push :files, { :controller => 'projects', :action => 'list_files' }, :caption => :label_attachment_plural
156 156 menu.push :repository, { :controller => 'repositories', :action => 'show' },
157 157 :if => Proc.new { |p| p.repository && !p.repository.new_record? }
158 158 menu.push :settings, { :controller => 'projects', :action => 'settings' }, :last => true
159 159 end
160 160
161 161 Redmine::Activity.map do |activity|
162 162 activity.register :issues, :class_name => %w(Issue Journal)
163 163 activity.register :changesets
164 164 activity.register :news
165 165 activity.register :documents, :class_name => %w(Document Attachment)
166 166 activity.register :files, :class_name => 'Attachment'
167 167 activity.register :wiki_edits, :class_name => 'WikiContent::Version', :default => false
168 168 activity.register :messages, :default => false
169 169 activity.register :time_entries, :default => false
170 170 end
171 171
172 172 Redmine::WikiFormatting.map do |format|
173 173 format.register :textile, Redmine::WikiFormatting::Textile::Formatter, Redmine::WikiFormatting::Textile::Helper
174 174 end
@@ -1,10 +1,11
1 class Version < ActiveRecord::Base
2 generator_for :name, :method => :next_name
3
4 def self.next_name
5 @last_name ||= 'Version 1.0.0'
6 @last_name.succ!
7 @last_name
8 end
9
10 end
1 class Version < ActiveRecord::Base
2 generator_for :name, :method => :next_name
3 generator_for :status => 'open'
4
5 def self.next_name
6 @last_name ||= 'Version 1.0.0'
7 @last_name.succ!
8 @last_name
9 end
10
11 end
@@ -1,136 +1,147
1 1 ---
2 2 attachments_001:
3 3 created_on: 2006-07-19 21:07:27 +02:00
4 4 downloads: 0
5 5 content_type: text/plain
6 6 disk_filename: 060719210727_error281.txt
7 7 container_id: 3
8 8 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
9 9 id: 1
10 10 container_type: Issue
11 11 filesize: 28
12 12 filename: error281.txt
13 13 author_id: 2
14 14 attachments_002:
15 15 created_on: 2007-01-27 15:08:27 +01:00
16 16 downloads: 0
17 17 content_type: text/plain
18 18 disk_filename: 060719210727_document.txt
19 19 container_id: 1
20 20 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
21 21 id: 2
22 22 container_type: Document
23 23 filesize: 28
24 24 filename: document.txt
25 25 author_id: 2
26 26 attachments_003:
27 27 created_on: 2006-07-19 21:07:27 +02:00
28 28 downloads: 0
29 29 content_type: image/gif
30 30 disk_filename: 060719210727_logo.gif
31 31 container_id: 4
32 32 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
33 33 id: 3
34 34 container_type: WikiPage
35 35 filesize: 280
36 36 filename: logo.gif
37 37 description: This is a logo
38 38 author_id: 2
39 39 attachments_004:
40 40 created_on: 2006-07-19 21:07:27 +02:00
41 41 container_type: Issue
42 42 container_id: 3
43 43 downloads: 0
44 44 disk_filename: 060719210727_source.rb
45 45 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
46 46 id: 4
47 47 filesize: 153
48 48 filename: source.rb
49 49 author_id: 2
50 50 description: This is a Ruby source file
51 51 content_type: application/x-ruby
52 52 attachments_005:
53 53 created_on: 2006-07-19 21:07:27 +02:00
54 54 container_type: Issue
55 55 container_id: 3
56 56 downloads: 0
57 57 disk_filename: 060719210727_changeset.diff
58 58 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
59 59 id: 5
60 60 filesize: 687
61 61 filename: changeset.diff
62 62 author_id: 2
63 63 content_type: text/x-diff
64 64 attachments_006:
65 65 created_on: 2006-07-19 21:07:27 +02:00
66 66 container_type: Issue
67 67 container_id: 3
68 68 downloads: 0
69 69 disk_filename: 060719210727_archive.zip
70 70 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
71 71 id: 6
72 72 filesize: 157
73 73 filename: archive.zip
74 74 author_id: 2
75 75 content_type: application/octet-stream
76 76 attachments_007:
77 77 created_on: 2006-07-19 21:07:27 +02:00
78 78 container_type: Issue
79 79 container_id: 4
80 80 downloads: 0
81 81 disk_filename: 060719210727_archive.zip
82 82 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
83 83 id: 7
84 84 filesize: 157
85 85 filename: archive.zip
86 86 author_id: 1
87 87 content_type: application/octet-stream
88 88 attachments_008:
89 89 created_on: 2006-07-19 21:07:27 +02:00
90 90 container_type: Project
91 91 container_id: 1
92 92 downloads: 0
93 93 disk_filename: 060719210727_project_file.zip
94 94 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
95 95 id: 8
96 96 filesize: 320
97 97 filename: project_file.zip
98 98 author_id: 2
99 99 content_type: application/octet-stream
100 100 attachments_009:
101 101 created_on: 2006-07-19 21:07:27 +02:00
102 102 container_type: Version
103 103 container_id: 1
104 104 downloads: 0
105 105 disk_filename: 060719210727_version_file.zip
106 106 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
107 107 id: 9
108 108 filesize: 452
109 109 filename: version_file.zip
110 110 author_id: 2
111 111 content_type: application/octet-stream
112 112 attachments_010:
113 113 created_on: 2006-07-19 21:07:27 +02:00
114 114 container_type: Issue
115 115 container_id: 2
116 116 downloads: 0
117 117 disk_filename: 060719210727_picture.jpg
118 118 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
119 119 id: 10
120 120 filesize: 452
121 121 filename: picture.jpg
122 122 author_id: 2
123 123 content_type: image/jpeg
124 124 attachments_011:
125 125 created_on: 2007-02-12 15:08:27 +01:00
126 126 container_type: Document
127 127 container_id: 1
128 128 downloads: 0
129 129 disk_filename: 060719210727_picture.jpg
130 130 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
131 131 id: 11
132 132 filesize: 452
133 133 filename: picture.jpg
134 134 author_id: 2
135 135 content_type: image/jpeg
136 No newline at end of file
136 attachments_012:
137 created_on: 2006-07-19 21:07:27 +02:00
138 container_type: Version
139 container_id: 1
140 downloads: 0
141 disk_filename: 060719210727_version_file.zip
142 digest: b91e08d0cf966d5c6ff411bd8c4cc3a2
143 id: 12
144 filesize: 452
145 filename: version_file.zip
146 author_id: 2
147 content_type: application/octet-stream
@@ -1,191 +1,205
1 1 ---
2 2 issues_001:
3 3 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
4 4 project_id: 1
5 5 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
6 6 priority_id: 4
7 7 subject: Can't print recipes
8 8 id: 1
9 9 fixed_version_id:
10 10 category_id: 1
11 11 description: Unable to print recipes
12 12 tracker_id: 1
13 13 assigned_to_id:
14 14 author_id: 2
15 15 status_id: 1
16 16 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
17 17 due_date: <%= 10.day.from_now.to_date.to_s(:db) %>
18 18 issues_002:
19 19 created_on: 2006-07-19 21:04:21 +02:00
20 20 project_id: 1
21 21 updated_on: 2006-07-19 21:09:50 +02:00
22 22 priority_id: 5
23 23 subject: Add ingredients categories
24 24 id: 2
25 25 fixed_version_id: 2
26 26 category_id:
27 27 description: Ingredients of the recipe should be classified by categories
28 28 tracker_id: 2
29 29 assigned_to_id: 3
30 30 author_id: 2
31 31 status_id: 2
32 32 start_date: <%= 2.day.ago.to_date.to_s(:db) %>
33 33 due_date:
34 34 issues_003:
35 35 created_on: 2006-07-19 21:07:27 +02:00
36 36 project_id: 1
37 37 updated_on: 2006-07-19 21:07:27 +02:00
38 38 priority_id: 4
39 39 subject: Error 281 when updating a recipe
40 40 id: 3
41 41 fixed_version_id:
42 42 category_id:
43 43 description: Error 281 is encountered when saving a recipe
44 44 tracker_id: 1
45 45 assigned_to_id: 3
46 46 author_id: 2
47 47 status_id: 1
48 48 start_date: <%= 1.day.from_now.to_date.to_s(:db) %>
49 49 due_date: <%= 40.day.ago.to_date.to_s(:db) %>
50 50 issues_004:
51 51 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
52 52 project_id: 2
53 53 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
54 54 priority_id: 4
55 55 subject: Issue on project 2
56 56 id: 4
57 57 fixed_version_id:
58 58 category_id:
59 59 description: Issue on project 2
60 60 tracker_id: 1
61 61 assigned_to_id: 2
62 62 author_id: 2
63 63 status_id: 1
64 64 issues_005:
65 65 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
66 66 project_id: 3
67 67 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
68 68 priority_id: 4
69 69 subject: Subproject issue
70 70 id: 5
71 71 fixed_version_id:
72 72 category_id:
73 73 description: This is an issue on a cookbook subproject
74 74 tracker_id: 1
75 75 assigned_to_id:
76 76 author_id: 2
77 77 status_id: 1
78 78 issues_006:
79 79 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
80 80 project_id: 5
81 81 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
82 82 priority_id: 4
83 83 subject: Issue of a private subproject
84 84 id: 6
85 85 fixed_version_id:
86 86 category_id:
87 87 description: This is an issue of a private subproject of cookbook
88 88 tracker_id: 1
89 89 assigned_to_id:
90 90 author_id: 2
91 91 status_id: 1
92 92 start_date: <%= Date.today.to_s(:db) %>
93 93 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
94 94 issues_007:
95 95 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
96 96 project_id: 1
97 97 updated_on: <%= 10.days.ago.to_date.to_s(:db) %>
98 98 priority_id: 5
99 99 subject: Issue due today
100 100 id: 7
101 101 fixed_version_id:
102 102 category_id:
103 103 description: This is an issue that is due today
104 104 tracker_id: 1
105 105 assigned_to_id:
106 106 author_id: 2
107 107 status_id: 1
108 108 start_date: <%= 10.days.ago.to_s(:db) %>
109 109 due_date: <%= Date.today.to_s(:db) %>
110 110 lock_version: 0
111 111 issues_008:
112 112 created_on: <%= 10.days.ago.to_date.to_s(:db) %>
113 113 project_id: 1
114 114 updated_on: <%= 10.days.ago.to_date.to_s(:db) %>
115 115 priority_id: 5
116 116 subject: Closed issue
117 117 id: 8
118 118 fixed_version_id:
119 119 category_id:
120 120 description: This is a closed issue.
121 121 tracker_id: 1
122 122 assigned_to_id:
123 123 author_id: 2
124 124 status_id: 5
125 125 start_date:
126 126 due_date:
127 127 lock_version: 0
128 128 issues_009:
129 129 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
130 130 project_id: 5
131 131 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
132 132 priority_id: 5
133 133 subject: Blocked Issue
134 134 id: 9
135 135 fixed_version_id:
136 136 category_id:
137 137 description: This is an issue that is blocked by issue #10
138 138 tracker_id: 1
139 139 assigned_to_id:
140 140 author_id: 2
141 141 status_id: 1
142 142 start_date: <%= Date.today.to_s(:db) %>
143 143 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
144 144 issues_010:
145 145 created_on: <%= 1.minute.ago.to_date.to_s(:db) %>
146 146 project_id: 5
147 147 updated_on: <%= 1.minute.ago.to_date.to_s(:db) %>
148 148 priority_id: 5
149 149 subject: Issue Doing the Blocking
150 150 id: 10
151 151 fixed_version_id:
152 152 category_id:
153 153 description: This is an issue that blocks issue #9
154 154 tracker_id: 1
155 155 assigned_to_id:
156 156 author_id: 2
157 157 status_id: 1
158 158 start_date: <%= Date.today.to_s(:db) %>
159 159 due_date: <%= 1.days.from_now.to_date.to_s(:db) %>
160 160 issues_011:
161 161 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
162 162 project_id: 1
163 163 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
164 164 priority_id: 5
165 165 subject: Closed issue on a closed version
166 166 id: 11
167 167 fixed_version_id: 1
168 168 category_id: 1
169 169 description:
170 170 tracker_id: 1
171 171 assigned_to_id:
172 172 author_id: 2
173 173 status_id: 5
174 174 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
175 175 due_date:
176 176 issues_012:
177 177 created_on: <%= 3.days.ago.to_date.to_s(:db) %>
178 178 project_id: 1
179 179 updated_on: <%= 1.day.ago.to_date.to_s(:db) %>
180 180 priority_id: 5
181 181 subject: Closed issue on a locked version
182 182 id: 12
183 183 fixed_version_id: 2
184 184 category_id: 1
185 185 description:
186 186 tracker_id: 1
187 187 assigned_to_id:
188 188 author_id: 3
189 189 status_id: 5
190 190 start_date: <%= 1.day.ago.to_date.to_s(:db) %>
191 191 due_date:
192 issues_013:
193 created_on: <%= 5.days.ago.to_date.to_s(:db) %>
194 project_id: 3
195 updated_on: <%= 2.days.ago.to_date.to_s(:db) %>
196 priority_id: 4
197 subject: Subproject issue two
198 id: 13
199 fixed_version_id:
200 category_id:
201 description: This is a second issue on a cookbook subproject
202 tracker_id: 1
203 assigned_to_id:
204 author_id: 2
205 status_id: 1
@@ -1,15 +1,22
1 1 ---
2 2 journal_details_001:
3 3 old_value: "1"
4 4 property: attr
5 5 id: 1
6 6 value: "2"
7 7 prop_key: status_id
8 8 journal_id: 1
9 9 journal_details_002:
10 10 old_value: "40"
11 11 property: attr
12 12 id: 2
13 13 value: "30"
14 14 prop_key: done_ratio
15 15 journal_id: 1
16 journal_details_003:
17 old_value: nil
18 property: attr
19 id: 3
20 value: "6"
21 prop_key: fixed_version_id
22 journal_id: 4
@@ -1,23 +1,29
1 1 ---
2 2 journals_001:
3 3 created_on: <%= 2.days.ago.to_date.to_s(:db) %>
4 4 notes: "Journal notes"
5 5 id: 1
6 6 journalized_type: Issue
7 7 user_id: 1
8 8 journalized_id: 1
9 9 journals_002:
10 10 created_on: <%= 1.days.ago.to_date.to_s(:db) %>
11 11 notes: "Some notes with Redmine links: #2, r2."
12 12 id: 2
13 13 journalized_type: Issue
14 14 user_id: 2
15 15 journalized_id: 1
16 16 journals_003:
17 17 created_on: <%= 1.days.ago.to_date.to_s(:db) %>
18 18 notes: "A comment with inline image: !picture.jpg!"
19 19 id: 3
20 20 journalized_type: Issue
21 21 user_id: 2
22 22 journalized_id: 2
23 No newline at end of file
23 journals_004:
24 created_on: <%= 1.days.ago.to_date.to_s(:db) %>
25 notes: "A comment with a private version."
26 id: 4
27 journalized_type: Issue
28 user_id: 1
29 journalized_id: 6
@@ -1,45 +1,50
1 1 ---
2 2 members_001:
3 3 created_on: 2006-07-19 19:35:33 +02:00
4 4 project_id: 1
5 5 id: 1
6 6 user_id: 2
7 7 mail_notification: true
8 8 members_002:
9 9 created_on: 2006-07-19 19:35:36 +02:00
10 10 project_id: 1
11 11 id: 2
12 12 user_id: 3
13 13 mail_notification: true
14 14 members_003:
15 15 created_on: 2006-07-19 19:35:36 +02:00
16 16 project_id: 2
17 17 id: 3
18 18 user_id: 2
19 19 mail_notification: true
20 20 members_004:
21 21 id: 4
22 22 created_on: 2006-07-19 19:35:36 +02:00
23 23 project_id: 1
24 24 # Locked user
25 25 user_id: 5
26 26 mail_notification: true
27 27 members_005:
28 28 id: 5
29 29 created_on: 2006-07-19 19:35:33 +02:00
30 30 project_id: 5
31 31 user_id: 2
32 32 mail_notification: true
33 33 members_006:
34 34 id: 6
35 35 created_on: 2006-07-19 19:35:33 +02:00
36 36 project_id: 5
37 37 user_id: 10
38 38 mail_notification: false
39 39 members_007:
40 40 id: 7
41 41 created_on: 2006-07-19 19:35:33 +02:00
42 42 project_id: 5
43 43 user_id: 8
44 44 mail_notification: false
45 No newline at end of file
45 members_008:
46 created_on: 2006-07-19 19:35:33 +02:00
47 project_id: 5
48 id: 8
49 user_id: 1
50 mail_notification: true
@@ -1,29 +1,71
1 1 ---
2 2 versions_001:
3 3 created_on: 2006-07-19 21:00:07 +02:00
4 4 name: "0.1"
5 5 project_id: 1
6 6 updated_on: 2006-07-19 21:00:07 +02:00
7 7 id: 1
8 8 description: Beta
9 9 effective_date: 2006-07-01
10 10 status: closed
11 sharing: 'none'
11 12 versions_002:
12 13 created_on: 2006-07-19 21:00:33 +02:00
13 14 name: "1.0"
14 15 project_id: 1
15 16 updated_on: 2006-07-19 21:00:33 +02:00
16 17 id: 2
17 18 description: Stable release
18 19 effective_date: <%= 20.day.from_now.to_date.to_s(:db) %>
19 20 status: locked
21 sharing: 'none'
20 22 versions_003:
21 23 created_on: 2006-07-19 21:00:33 +02:00
22 24 name: "2.0"
23 25 project_id: 1
24 26 updated_on: 2006-07-19 21:00:33 +02:00
25 27 id: 3
26 28 description: Future version
27 29 effective_date:
28 30 status: open
29 No newline at end of file
31 sharing: 'none'
32 versions_004:
33 created_on: 2006-07-19 21:00:33 +02:00
34 name: "2.0"
35 project_id: 3
36 updated_on: 2006-07-19 21:00:33 +02:00
37 id: 4
38 description: Future version on subproject
39 effective_date:
40 status: open
41 sharing: 'tree'
42 versions_005:
43 created_on: 2006-07-19 21:00:07 +02:00
44 name: "Alpha"
45 project_id: 2
46 updated_on: 2006-07-19 21:00:07 +02:00
47 id: 5
48 description: Private Alpha
49 effective_date: 2006-07-01
50 status: open
51 sharing: 'none'
52 versions_006:
53 created_on: 2006-07-19 21:00:07 +02:00
54 name: "Private Version of public subproject"
55 project_id: 5
56 updated_on: 2006-07-19 21:00:07 +02:00
57 id: 6
58 description: "Should be done any day now..."
59 effective_date:
60 status: open
61 sharing: 'tree'
62 versions_007:
63 created_on: 2006-07-19 21:00:07 +02:00
64 name: "Systemwide visible version"
65 project_id: 2
66 updated_on: 2006-07-19 21:00:07 +02:00
67 id: 7
68 description:
69 effective_date:
70 status: open
71 sharing: 'system'
@@ -1,1218 +1,1257
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 File.dirname(__FILE__) + '/../test_helper'
19 19 require 'issues_controller'
20 20
21 21 # Re-raise errors caught by the controller.
22 22 class IssuesController; def rescue_action(e) raise e end; end
23 23
24 24 class IssuesControllerTest < ActionController::TestCase
25 25 fixtures :projects,
26 26 :users,
27 27 :roles,
28 28 :members,
29 29 :member_roles,
30 30 :issues,
31 31 :issue_statuses,
32 32 :versions,
33 33 :trackers,
34 34 :projects_trackers,
35 35 :issue_categories,
36 36 :enabled_modules,
37 37 :enumerations,
38 38 :attachments,
39 39 :workflows,
40 40 :custom_fields,
41 41 :custom_values,
42 42 :custom_fields_projects,
43 43 :custom_fields_trackers,
44 44 :time_entries,
45 45 :journals,
46 46 :journal_details,
47 47 :queries
48 48
49 49 def setup
50 50 @controller = IssuesController.new
51 51 @request = ActionController::TestRequest.new
52 52 @response = ActionController::TestResponse.new
53 53 User.current = nil
54 54 end
55 55
56 56 def test_index_routing
57 57 assert_routing(
58 58 {:method => :get, :path => '/issues'},
59 59 :controller => 'issues', :action => 'index'
60 60 )
61 61 end
62 62
63 63 def test_index
64 64 Setting.default_language = 'en'
65 65
66 66 get :index
67 67 assert_response :success
68 68 assert_template 'index.rhtml'
69 69 assert_not_nil assigns(:issues)
70 70 assert_nil assigns(:project)
71 71 assert_tag :tag => 'a', :content => /Can't print recipes/
72 72 assert_tag :tag => 'a', :content => /Subproject issue/
73 73 # private projects hidden
74 74 assert_no_tag :tag => 'a', :content => /Issue of a private subproject/
75 75 assert_no_tag :tag => 'a', :content => /Issue on project 2/
76 76 # project column
77 77 assert_tag :tag => 'th', :content => /Project/
78 78 end
79 79
80 80 def test_index_should_not_list_issues_when_module_disabled
81 81 EnabledModule.delete_all("name = 'issue_tracking' AND project_id = 1")
82 82 get :index
83 83 assert_response :success
84 84 assert_template 'index.rhtml'
85 85 assert_not_nil assigns(:issues)
86 86 assert_nil assigns(:project)
87 87 assert_no_tag :tag => 'a', :content => /Can't print recipes/
88 88 assert_tag :tag => 'a', :content => /Subproject issue/
89 89 end
90 90
91 91 def test_index_with_project_routing
92 92 assert_routing(
93 93 {:method => :get, :path => '/projects/23/issues'},
94 94 :controller => 'issues', :action => 'index', :project_id => '23'
95 95 )
96 96 end
97 97
98 98 def test_index_should_not_list_issues_when_module_disabled
99 99 EnabledModule.delete_all("name = 'issue_tracking' AND project_id = 1")
100 100 get :index
101 101 assert_response :success
102 102 assert_template 'index.rhtml'
103 103 assert_not_nil assigns(:issues)
104 104 assert_nil assigns(:project)
105 105 assert_no_tag :tag => 'a', :content => /Can't print recipes/
106 106 assert_tag :tag => 'a', :content => /Subproject issue/
107 107 end
108 108
109 109 def test_index_with_project_routing
110 110 assert_routing(
111 111 {:method => :get, :path => 'projects/23/issues'},
112 112 :controller => 'issues', :action => 'index', :project_id => '23'
113 113 )
114 114 end
115 115
116 116 def test_index_with_project
117 117 Setting.display_subprojects_issues = 0
118 118 get :index, :project_id => 1
119 119 assert_response :success
120 120 assert_template 'index.rhtml'
121 121 assert_not_nil assigns(:issues)
122 122 assert_tag :tag => 'a', :content => /Can't print recipes/
123 123 assert_no_tag :tag => 'a', :content => /Subproject issue/
124 124 end
125 125
126 126 def test_index_with_project_and_subprojects
127 127 Setting.display_subprojects_issues = 1
128 128 get :index, :project_id => 1
129 129 assert_response :success
130 130 assert_template 'index.rhtml'
131 131 assert_not_nil assigns(:issues)
132 132 assert_tag :tag => 'a', :content => /Can't print recipes/
133 133 assert_tag :tag => 'a', :content => /Subproject issue/
134 134 assert_no_tag :tag => 'a', :content => /Issue of a private subproject/
135 135 end
136 136
137 137 def test_index_with_project_and_subprojects_should_show_private_subprojects
138 138 @request.session[:user_id] = 2
139 139 Setting.display_subprojects_issues = 1
140 140 get :index, :project_id => 1
141 141 assert_response :success
142 142 assert_template 'index.rhtml'
143 143 assert_not_nil assigns(:issues)
144 144 assert_tag :tag => 'a', :content => /Can't print recipes/
145 145 assert_tag :tag => 'a', :content => /Subproject issue/
146 146 assert_tag :tag => 'a', :content => /Issue of a private subproject/
147 147 end
148 148
149 149 def test_index_with_project_routing_formatted
150 150 assert_routing(
151 151 {:method => :get, :path => 'projects/23/issues.pdf'},
152 152 :controller => 'issues', :action => 'index', :project_id => '23', :format => 'pdf'
153 153 )
154 154 assert_routing(
155 155 {:method => :get, :path => 'projects/23/issues.atom'},
156 156 :controller => 'issues', :action => 'index', :project_id => '23', :format => 'atom'
157 157 )
158 158 end
159 159
160 160 def test_index_with_project_and_filter
161 161 get :index, :project_id => 1, :set_filter => 1
162 162 assert_response :success
163 163 assert_template 'index.rhtml'
164 164 assert_not_nil assigns(:issues)
165 165 end
166 166
167 167 def test_index_with_query
168 168 get :index, :project_id => 1, :query_id => 5
169 169 assert_response :success
170 170 assert_template 'index.rhtml'
171 171 assert_not_nil assigns(:issues)
172 172 assert_nil assigns(:issue_count_by_group)
173 173 end
174 174
175 175 def test_index_with_query_grouped_by_tracker
176 176 get :index, :project_id => 1, :query_id => 6
177 177 assert_response :success
178 178 assert_template 'index.rhtml'
179 179 assert_not_nil assigns(:issues)
180 180 assert_not_nil assigns(:issue_count_by_group)
181 181 end
182 182
183 183 def test_index_with_query_grouped_by_list_custom_field
184 184 get :index, :project_id => 1, :query_id => 9
185 185 assert_response :success
186 186 assert_template 'index.rhtml'
187 187 assert_not_nil assigns(:issues)
188 188 assert_not_nil assigns(:issue_count_by_group)
189 189 end
190 190
191 191 def test_index_sort_by_field_not_included_in_columns
192 192 Setting.issue_list_default_columns = %w(subject author)
193 193 get :index, :sort => 'tracker'
194 194 end
195 195
196 196 def test_index_csv_with_project
197 197 Setting.default_language = 'en'
198 198
199 199 get :index, :format => 'csv'
200 200 assert_response :success
201 201 assert_not_nil assigns(:issues)
202 202 assert_equal 'text/csv', @response.content_type
203 203 assert @response.body.starts_with?("#,")
204 204
205 205 get :index, :project_id => 1, :format => 'csv'
206 206 assert_response :success
207 207 assert_not_nil assigns(:issues)
208 208 assert_equal 'text/csv', @response.content_type
209 209 end
210 210
211 211 def test_index_formatted
212 212 assert_routing(
213 213 {:method => :get, :path => 'issues.pdf'},
214 214 :controller => 'issues', :action => 'index', :format => 'pdf'
215 215 )
216 216 assert_routing(
217 217 {:method => :get, :path => 'issues.atom'},
218 218 :controller => 'issues', :action => 'index', :format => 'atom'
219 219 )
220 220 end
221 221
222 222 def test_index_pdf
223 223 get :index, :format => 'pdf'
224 224 assert_response :success
225 225 assert_not_nil assigns(:issues)
226 226 assert_equal 'application/pdf', @response.content_type
227 227
228 228 get :index, :project_id => 1, :format => 'pdf'
229 229 assert_response :success
230 230 assert_not_nil assigns(:issues)
231 231 assert_equal 'application/pdf', @response.content_type
232 232
233 233 get :index, :project_id => 1, :query_id => 6, :format => 'pdf'
234 234 assert_response :success
235 235 assert_not_nil assigns(:issues)
236 236 assert_equal 'application/pdf', @response.content_type
237 237 end
238 238
239 239 def test_index_sort
240 240 get :index, :sort => 'tracker,id:desc'
241 241 assert_response :success
242 242
243 243 sort_params = @request.session['issues_index_sort']
244 244 assert sort_params.is_a?(String)
245 245 assert_equal 'tracker,id:desc', sort_params
246 246
247 247 issues = assigns(:issues)
248 248 assert_not_nil issues
249 249 assert !issues.empty?
250 250 assert_equal issues.sort {|a,b| a.tracker == b.tracker ? b.id <=> a.id : a.tracker <=> b.tracker }.collect(&:id), issues.collect(&:id)
251 251 end
252 252
253 253 def test_index_with_columns
254 254 columns = ['tracker', 'subject', 'assigned_to']
255 255 get :index, :set_filter => 1, :query => { 'column_names' => columns}
256 256 assert_response :success
257 257
258 258 # query should use specified columns
259 259 query = assigns(:query)
260 260 assert_kind_of Query, query
261 261 assert_equal columns, query.column_names.map(&:to_s)
262 262
263 263 # columns should be stored in session
264 264 assert_kind_of Hash, session[:query]
265 265 assert_kind_of Array, session[:query][:column_names]
266 266 assert_equal columns, session[:query][:column_names].map(&:to_s)
267 267 end
268 268
269 269 def test_gantt
270 270 get :gantt, :project_id => 1
271 271 assert_response :success
272 272 assert_template 'gantt.rhtml'
273 273 assert_not_nil assigns(:gantt)
274 274 events = assigns(:gantt).events
275 275 assert_not_nil events
276 276 # Issue with start and due dates
277 277 i = Issue.find(1)
278 278 assert_not_nil i.due_date
279 279 assert events.include?(Issue.find(1))
280 280 # Issue with without due date but targeted to a version with date
281 281 i = Issue.find(2)
282 282 assert_nil i.due_date
283 283 assert events.include?(i)
284 284 end
285 285
286 286 def test_cross_project_gantt
287 287 get :gantt
288 288 assert_response :success
289 289 assert_template 'gantt.rhtml'
290 290 assert_not_nil assigns(:gantt)
291 291 events = assigns(:gantt).events
292 292 assert_not_nil events
293 293 end
294 294
295 295 def test_gantt_export_to_pdf
296 296 get :gantt, :project_id => 1, :format => 'pdf'
297 297 assert_response :success
298 298 assert_equal 'application/pdf', @response.content_type
299 299 assert @response.body.starts_with?('%PDF')
300 300 assert_not_nil assigns(:gantt)
301 301 end
302 302
303 303 def test_cross_project_gantt_export_to_pdf
304 304 get :gantt, :format => 'pdf'
305 305 assert_response :success
306 306 assert_equal 'application/pdf', @response.content_type
307 307 assert @response.body.starts_with?('%PDF')
308 308 assert_not_nil assigns(:gantt)
309 309 end
310 310
311 311 if Object.const_defined?(:Magick)
312 312 def test_gantt_image
313 313 get :gantt, :project_id => 1, :format => 'png'
314 314 assert_response :success
315 315 assert_equal 'image/png', @response.content_type
316 316 end
317 317 else
318 318 puts "RMagick not installed. Skipping tests !!!"
319 319 end
320 320
321 321 def test_calendar
322 322 get :calendar, :project_id => 1
323 323 assert_response :success
324 324 assert_template 'calendar'
325 325 assert_not_nil assigns(:calendar)
326 326 end
327 327
328 328 def test_cross_project_calendar
329 329 get :calendar
330 330 assert_response :success
331 331 assert_template 'calendar'
332 332 assert_not_nil assigns(:calendar)
333 333 end
334 334
335 335 def test_changes
336 336 get :changes, :project_id => 1
337 337 assert_response :success
338 338 assert_not_nil assigns(:journals)
339 339 assert_equal 'application/atom+xml', @response.content_type
340 340 end
341 341
342 342 def test_show_routing
343 343 assert_routing(
344 344 {:method => :get, :path => '/issues/64'},
345 345 :controller => 'issues', :action => 'show', :id => '64'
346 346 )
347 347 end
348 348
349 349 def test_show_routing_formatted
350 350 assert_routing(
351 351 {:method => :get, :path => '/issues/2332.pdf'},
352 352 :controller => 'issues', :action => 'show', :id => '2332', :format => 'pdf'
353 353 )
354 354 assert_routing(
355 355 {:method => :get, :path => '/issues/23123.atom'},
356 356 :controller => 'issues', :action => 'show', :id => '23123', :format => 'atom'
357 357 )
358 358 end
359 359
360 360 def test_show_by_anonymous
361 361 get :show, :id => 1
362 362 assert_response :success
363 363 assert_template 'show.rhtml'
364 364 assert_not_nil assigns(:issue)
365 365 assert_equal Issue.find(1), assigns(:issue)
366 366
367 367 # anonymous role is allowed to add a note
368 368 assert_tag :tag => 'form',
369 369 :descendant => { :tag => 'fieldset',
370 370 :child => { :tag => 'legend',
371 371 :content => /Notes/ } }
372 372 end
373 373
374 374 def test_show_by_manager
375 375 @request.session[:user_id] = 2
376 376 get :show, :id => 1
377 377 assert_response :success
378 378
379 379 assert_tag :tag => 'form',
380 380 :descendant => { :tag => 'fieldset',
381 381 :child => { :tag => 'legend',
382 382 :content => /Change properties/ } },
383 383 :descendant => { :tag => 'fieldset',
384 384 :child => { :tag => 'legend',
385 385 :content => /Log time/ } },
386 386 :descendant => { :tag => 'fieldset',
387 387 :child => { :tag => 'legend',
388 388 :content => /Notes/ } }
389 389 end
390 390
391 391 def test_show_should_deny_anonymous_access_without_permission
392 392 Role.anonymous.remove_permission!(:view_issues)
393 393 get :show, :id => 1
394 394 assert_response :redirect
395 395 end
396 396
397 397 def test_show_should_deny_non_member_access_without_permission
398 398 Role.non_member.remove_permission!(:view_issues)
399 399 @request.session[:user_id] = 9
400 400 get :show, :id => 1
401 401 assert_response 403
402 402 end
403 403
404 404 def test_show_should_deny_member_access_without_permission
405 405 Role.find(1).remove_permission!(:view_issues)
406 406 @request.session[:user_id] = 2
407 407 get :show, :id => 1
408 408 assert_response 403
409 409 end
410 410
411 411 def test_show_should_not_disclose_relations_to_invisible_issues
412 412 Setting.cross_project_issue_relations = '1'
413 413 IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => 'relates')
414 414 # Relation to a private project issue
415 415 IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(4), :relation_type => 'relates')
416 416
417 417 get :show, :id => 1
418 418 assert_response :success
419 419
420 420 assert_tag :div, :attributes => { :id => 'relations' },
421 421 :descendant => { :tag => 'a', :content => /#2$/ }
422 422 assert_no_tag :div, :attributes => { :id => 'relations' },
423 423 :descendant => { :tag => 'a', :content => /#4$/ }
424 424 end
425 425
426 426 def test_show_atom
427 427 get :show, :id => 2, :format => 'atom'
428 428 assert_response :success
429 429 assert_template 'changes.rxml'
430 430 # Inline image
431 431 assert @response.body.include?("&lt;img src=\"http://test.host/attachments/download/10\" alt=\"\" /&gt;"), "Body did not match. Body: #{@response.body}"
432 432 end
433 433
434 434 def test_new_routing
435 435 assert_routing(
436 436 {:method => :get, :path => '/projects/1/issues/new'},
437 437 :controller => 'issues', :action => 'new', :project_id => '1'
438 438 )
439 439 assert_recognizes(
440 440 {:controller => 'issues', :action => 'new', :project_id => '1'},
441 441 {:method => :post, :path => '/projects/1/issues'}
442 442 )
443 443 end
444 444
445 445 def test_show_export_to_pdf
446 446 get :show, :id => 3, :format => 'pdf'
447 447 assert_response :success
448 448 assert_equal 'application/pdf', @response.content_type
449 449 assert @response.body.starts_with?('%PDF')
450 450 assert_not_nil assigns(:issue)
451 451 end
452 452
453 453 def test_get_new
454 454 @request.session[:user_id] = 2
455 455 get :new, :project_id => 1, :tracker_id => 1
456 456 assert_response :success
457 457 assert_template 'new'
458 458
459 459 assert_tag :tag => 'input', :attributes => { :name => 'issue[custom_field_values][2]',
460 460 :value => 'Default string' }
461 461 end
462 462
463 463 def test_get_new_without_tracker_id
464 464 @request.session[:user_id] = 2
465 465 get :new, :project_id => 1
466 466 assert_response :success
467 467 assert_template 'new'
468 468
469 469 issue = assigns(:issue)
470 470 assert_not_nil issue
471 471 assert_equal Project.find(1).trackers.first, issue.tracker
472 472 end
473 473
474 474 def test_get_new_with_no_default_status_should_display_an_error
475 475 @request.session[:user_id] = 2
476 476 IssueStatus.delete_all
477 477
478 478 get :new, :project_id => 1
479 479 assert_response 500
480 480 assert_not_nil flash[:error]
481 481 assert_tag :tag => 'div', :attributes => { :class => /error/ },
482 482 :content => /No default issue/
483 483 end
484 484
485 485 def test_get_new_with_no_tracker_should_display_an_error
486 486 @request.session[:user_id] = 2
487 487 Tracker.delete_all
488 488
489 489 get :new, :project_id => 1
490 490 assert_response 500
491 491 assert_not_nil flash[:error]
492 492 assert_tag :tag => 'div', :attributes => { :class => /error/ },
493 493 :content => /No tracker/
494 494 end
495 495
496 496 def test_update_new_form
497 497 @request.session[:user_id] = 2
498 498 xhr :post, :update_form, :project_id => 1,
499 499 :issue => {:tracker_id => 2,
500 500 :subject => 'This is the test_new issue',
501 501 :description => 'This is the description',
502 502 :priority_id => 5}
503 503 assert_response :success
504 504 assert_template 'attributes'
505 505
506 506 issue = assigns(:issue)
507 507 assert_kind_of Issue, issue
508 508 assert_equal 1, issue.project_id
509 509 assert_equal 2, issue.tracker_id
510 510 assert_equal 'This is the test_new issue', issue.subject
511 511 end
512 512
513 513 def test_post_new
514 514 @request.session[:user_id] = 2
515 515 assert_difference 'Issue.count' do
516 516 post :new, :project_id => 1,
517 517 :issue => {:tracker_id => 3,
518 518 :subject => 'This is the test_new issue',
519 519 :description => 'This is the description',
520 520 :priority_id => 5,
521 521 :estimated_hours => '',
522 522 :custom_field_values => {'2' => 'Value for field 2'}}
523 523 end
524 524 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
525 525
526 526 issue = Issue.find_by_subject('This is the test_new issue')
527 527 assert_not_nil issue
528 528 assert_equal 2, issue.author_id
529 529 assert_equal 3, issue.tracker_id
530 530 assert_nil issue.estimated_hours
531 531 v = issue.custom_values.find(:first, :conditions => {:custom_field_id => 2})
532 532 assert_not_nil v
533 533 assert_equal 'Value for field 2', v.value
534 534 end
535 535
536 536 def test_post_new_and_continue
537 537 @request.session[:user_id] = 2
538 538 post :new, :project_id => 1,
539 539 :issue => {:tracker_id => 3,
540 540 :subject => 'This is first issue',
541 541 :priority_id => 5},
542 542 :continue => ''
543 543 assert_redirected_to :controller => 'issues', :action => 'new', :tracker_id => 3
544 544 end
545 545
546 546 def test_post_new_without_custom_fields_param
547 547 @request.session[:user_id] = 2
548 548 assert_difference 'Issue.count' do
549 549 post :new, :project_id => 1,
550 550 :issue => {:tracker_id => 1,
551 551 :subject => 'This is the test_new issue',
552 552 :description => 'This is the description',
553 553 :priority_id => 5}
554 554 end
555 555 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
556 556 end
557 557
558 558 def test_post_new_with_required_custom_field_and_without_custom_fields_param
559 559 field = IssueCustomField.find_by_name('Database')
560 560 field.update_attribute(:is_required, true)
561 561
562 562 @request.session[:user_id] = 2
563 563 post :new, :project_id => 1,
564 564 :issue => {:tracker_id => 1,
565 565 :subject => 'This is the test_new issue',
566 566 :description => 'This is the description',
567 567 :priority_id => 5}
568 568 assert_response :success
569 569 assert_template 'new'
570 570 issue = assigns(:issue)
571 571 assert_not_nil issue
572 572 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
573 573 end
574 574
575 575 def test_post_new_with_watchers
576 576 @request.session[:user_id] = 2
577 577 ActionMailer::Base.deliveries.clear
578 578
579 579 assert_difference 'Watcher.count', 2 do
580 580 post :new, :project_id => 1,
581 581 :issue => {:tracker_id => 1,
582 582 :subject => 'This is a new issue with watchers',
583 583 :description => 'This is the description',
584 584 :priority_id => 5,
585 585 :watcher_user_ids => ['2', '3']}
586 586 end
587 587 issue = Issue.find_by_subject('This is a new issue with watchers')
588 588 assert_not_nil issue
589 589 assert_redirected_to :controller => 'issues', :action => 'show', :id => issue
590 590
591 591 # Watchers added
592 592 assert_equal [2, 3], issue.watcher_user_ids.sort
593 593 assert issue.watched_by?(User.find(3))
594 594 # Watchers notified
595 595 mail = ActionMailer::Base.deliveries.last
596 596 assert_kind_of TMail::Mail, mail
597 597 assert [mail.bcc, mail.cc].flatten.include?(User.find(3).mail)
598 598 end
599 599
600 600 def test_post_new_should_send_a_notification
601 601 ActionMailer::Base.deliveries.clear
602 602 @request.session[:user_id] = 2
603 603 assert_difference 'Issue.count' do
604 604 post :new, :project_id => 1,
605 605 :issue => {:tracker_id => 3,
606 606 :subject => 'This is the test_new issue',
607 607 :description => 'This is the description',
608 608 :priority_id => 5,
609 609 :estimated_hours => '',
610 610 :custom_field_values => {'2' => 'Value for field 2'}}
611 611 end
612 612 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
613 613
614 614 assert_equal 1, ActionMailer::Base.deliveries.size
615 615 end
616 616
617 617 def test_post_should_preserve_fields_values_on_validation_failure
618 618 @request.session[:user_id] = 2
619 619 post :new, :project_id => 1,
620 620 :issue => {:tracker_id => 1,
621 621 # empty subject
622 622 :subject => '',
623 623 :description => 'This is a description',
624 624 :priority_id => 6,
625 625 :custom_field_values => {'1' => 'Oracle', '2' => 'Value for field 2'}}
626 626 assert_response :success
627 627 assert_template 'new'
628 628
629 629 assert_tag :textarea, :attributes => { :name => 'issue[description]' },
630 630 :content => 'This is a description'
631 631 assert_tag :select, :attributes => { :name => 'issue[priority_id]' },
632 632 :child => { :tag => 'option', :attributes => { :selected => 'selected',
633 633 :value => '6' },
634 634 :content => 'High' }
635 635 # Custom fields
636 636 assert_tag :select, :attributes => { :name => 'issue[custom_field_values][1]' },
637 637 :child => { :tag => 'option', :attributes => { :selected => 'selected',
638 638 :value => 'Oracle' },
639 639 :content => 'Oracle' }
640 640 assert_tag :input, :attributes => { :name => 'issue[custom_field_values][2]',
641 641 :value => 'Value for field 2'}
642 642 end
643 643
644 644 def test_copy_routing
645 645 assert_routing(
646 646 {:method => :get, :path => '/projects/world_domination/issues/567/copy'},
647 647 :controller => 'issues', :action => 'new', :project_id => 'world_domination', :copy_from => '567'
648 648 )
649 649 end
650 650
651 651 def test_copy_issue
652 652 @request.session[:user_id] = 2
653 653 get :new, :project_id => 1, :copy_from => 1
654 654 assert_template 'new'
655 655 assert_not_nil assigns(:issue)
656 656 orig = Issue.find(1)
657 657 assert_equal orig.subject, assigns(:issue).subject
658 658 end
659 659
660 660 def test_edit_routing
661 661 assert_routing(
662 662 {:method => :get, :path => '/issues/1/edit'},
663 663 :controller => 'issues', :action => 'edit', :id => '1'
664 664 )
665 665 assert_recognizes( #TODO: use a PUT on the issue URI isntead, need to adjust form
666 666 {:controller => 'issues', :action => 'edit', :id => '1'},
667 667 {:method => :post, :path => '/issues/1/edit'}
668 668 )
669 669 end
670 670
671 671 def test_get_edit
672 672 @request.session[:user_id] = 2
673 673 get :edit, :id => 1
674 674 assert_response :success
675 675 assert_template 'edit'
676 676 assert_not_nil assigns(:issue)
677 677 assert_equal Issue.find(1), assigns(:issue)
678 678 end
679 679
680 680 def test_get_edit_with_params
681 681 @request.session[:user_id] = 2
682 682 get :edit, :id => 1, :issue => { :status_id => 5, :priority_id => 7 }
683 683 assert_response :success
684 684 assert_template 'edit'
685 685
686 686 issue = assigns(:issue)
687 687 assert_not_nil issue
688 688
689 689 assert_equal 5, issue.status_id
690 690 assert_tag :select, :attributes => { :name => 'issue[status_id]' },
691 691 :child => { :tag => 'option',
692 692 :content => 'Closed',
693 693 :attributes => { :selected => 'selected' } }
694 694
695 695 assert_equal 7, issue.priority_id
696 696 assert_tag :select, :attributes => { :name => 'issue[priority_id]' },
697 697 :child => { :tag => 'option',
698 698 :content => 'Urgent',
699 699 :attributes => { :selected => 'selected' } }
700 700 end
701 701
702 702 def test_update_edit_form
703 703 @request.session[:user_id] = 2
704 704 xhr :post, :update_form, :project_id => 1,
705 705 :id => 1,
706 706 :issue => {:tracker_id => 2,
707 707 :subject => 'This is the test_new issue',
708 708 :description => 'This is the description',
709 709 :priority_id => 5}
710 710 assert_response :success
711 711 assert_template 'attributes'
712 712
713 713 issue = assigns(:issue)
714 714 assert_kind_of Issue, issue
715 715 assert_equal 1, issue.id
716 716 assert_equal 1, issue.project_id
717 717 assert_equal 2, issue.tracker_id
718 718 assert_equal 'This is the test_new issue', issue.subject
719 719 end
720 720
721 721 def test_reply_routing
722 722 assert_routing(
723 723 {:method => :post, :path => '/issues/1/quoted'},
724 724 :controller => 'issues', :action => 'reply', :id => '1'
725 725 )
726 726 end
727 727
728 728 def test_reply_to_issue
729 729 @request.session[:user_id] = 2
730 730 get :reply, :id => 1
731 731 assert_response :success
732 732 assert_select_rjs :show, "update"
733 733 end
734 734
735 735 def test_reply_to_note
736 736 @request.session[:user_id] = 2
737 737 get :reply, :id => 1, :journal_id => 2
738 738 assert_response :success
739 739 assert_select_rjs :show, "update"
740 740 end
741 741
742 742 def test_post_edit_without_custom_fields_param
743 743 @request.session[:user_id] = 2
744 744 ActionMailer::Base.deliveries.clear
745 745
746 746 issue = Issue.find(1)
747 747 assert_equal '125', issue.custom_value_for(2).value
748 748 old_subject = issue.subject
749 749 new_subject = 'Subject modified by IssuesControllerTest#test_post_edit'
750 750
751 751 assert_difference('Journal.count') do
752 752 assert_difference('JournalDetail.count', 2) do
753 753 post :edit, :id => 1, :issue => {:subject => new_subject,
754 754 :priority_id => '6',
755 755 :category_id => '1' # no change
756 756 }
757 757 end
758 758 end
759 759 assert_redirected_to :action => 'show', :id => '1'
760 760 issue.reload
761 761 assert_equal new_subject, issue.subject
762 762 # Make sure custom fields were not cleared
763 763 assert_equal '125', issue.custom_value_for(2).value
764 764
765 765 mail = ActionMailer::Base.deliveries.last
766 766 assert_kind_of TMail::Mail, mail
767 767 assert mail.subject.starts_with?("[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}]")
768 768 assert mail.body.include?("Subject changed from #{old_subject} to #{new_subject}")
769 769 end
770 770
771 771 def test_post_edit_with_custom_field_change
772 772 @request.session[:user_id] = 2
773 773 issue = Issue.find(1)
774 774 assert_equal '125', issue.custom_value_for(2).value
775 775
776 776 assert_difference('Journal.count') do
777 777 assert_difference('JournalDetail.count', 3) do
778 778 post :edit, :id => 1, :issue => {:subject => 'Custom field change',
779 779 :priority_id => '6',
780 780 :category_id => '1', # no change
781 781 :custom_field_values => { '2' => 'New custom value' }
782 782 }
783 783 end
784 784 end
785 785 assert_redirected_to :action => 'show', :id => '1'
786 786 issue.reload
787 787 assert_equal 'New custom value', issue.custom_value_for(2).value
788 788
789 789 mail = ActionMailer::Base.deliveries.last
790 790 assert_kind_of TMail::Mail, mail
791 791 assert mail.body.include?("Searchable field changed from 125 to New custom value")
792 792 end
793 793
794 794 def test_post_edit_with_status_and_assignee_change
795 795 issue = Issue.find(1)
796 796 assert_equal 1, issue.status_id
797 797 @request.session[:user_id] = 2
798 798 assert_difference('TimeEntry.count', 0) do
799 799 post :edit,
800 800 :id => 1,
801 801 :issue => { :status_id => 2, :assigned_to_id => 3 },
802 802 :notes => 'Assigned to dlopper',
803 803 :time_entry => { :hours => '', :comments => '', :activity_id => TimeEntryActivity.first }
804 804 end
805 805 assert_redirected_to :action => 'show', :id => '1'
806 806 issue.reload
807 807 assert_equal 2, issue.status_id
808 808 j = issue.journals.find(:first, :order => 'id DESC')
809 809 assert_equal 'Assigned to dlopper', j.notes
810 810 assert_equal 2, j.details.size
811 811
812 812 mail = ActionMailer::Base.deliveries.last
813 813 assert mail.body.include?("Status changed from New to Assigned")
814 814 # subject should contain the new status
815 815 assert mail.subject.include?("(#{ IssueStatus.find(2).name })")
816 816 end
817 817
818 818 def test_post_edit_with_note_only
819 819 notes = 'Note added by IssuesControllerTest#test_update_with_note_only'
820 820 # anonymous user
821 821 post :edit,
822 822 :id => 1,
823 823 :notes => notes
824 824 assert_redirected_to :action => 'show', :id => '1'
825 825 j = Issue.find(1).journals.find(:first, :order => 'id DESC')
826 826 assert_equal notes, j.notes
827 827 assert_equal 0, j.details.size
828 828 assert_equal User.anonymous, j.user
829 829
830 830 mail = ActionMailer::Base.deliveries.last
831 831 assert mail.body.include?(notes)
832 832 end
833 833
834 834 def test_post_edit_with_note_and_spent_time
835 835 @request.session[:user_id] = 2
836 836 spent_hours_before = Issue.find(1).spent_hours
837 837 assert_difference('TimeEntry.count') do
838 838 post :edit,
839 839 :id => 1,
840 840 :notes => '2.5 hours added',
841 841 :time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first }
842 842 end
843 843 assert_redirected_to :action => 'show', :id => '1'
844 844
845 845 issue = Issue.find(1)
846 846
847 847 j = issue.journals.find(:first, :order => 'id DESC')
848 848 assert_equal '2.5 hours added', j.notes
849 849 assert_equal 0, j.details.size
850 850
851 851 t = issue.time_entries.find(:first, :order => 'id DESC')
852 852 assert_not_nil t
853 853 assert_equal 2.5, t.hours
854 854 assert_equal spent_hours_before + 2.5, issue.spent_hours
855 855 end
856 856
857 857 def test_post_edit_with_attachment_only
858 858 set_tmp_attachments_directory
859 859
860 860 # Delete all fixtured journals, a race condition can occur causing the wrong
861 861 # journal to get fetched in the next find.
862 862 Journal.delete_all
863 863
864 864 # anonymous user
865 865 post :edit,
866 866 :id => 1,
867 867 :notes => '',
868 868 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}}
869 869 assert_redirected_to :action => 'show', :id => '1'
870 870 j = Issue.find(1).journals.find(:first, :order => 'id DESC')
871 871 assert j.notes.blank?
872 872 assert_equal 1, j.details.size
873 873 assert_equal 'testfile.txt', j.details.first.value
874 874 assert_equal User.anonymous, j.user
875 875
876 876 mail = ActionMailer::Base.deliveries.last
877 877 assert mail.body.include?('testfile.txt')
878 878 end
879 879
880 880 def test_post_edit_with_no_change
881 881 issue = Issue.find(1)
882 882 issue.journals.clear
883 883 ActionMailer::Base.deliveries.clear
884 884
885 885 post :edit,
886 886 :id => 1,
887 887 :notes => ''
888 888 assert_redirected_to :action => 'show', :id => '1'
889 889
890 890 issue.reload
891 891 assert issue.journals.empty?
892 892 # No email should be sent
893 893 assert ActionMailer::Base.deliveries.empty?
894 894 end
895 895
896 896 def test_post_edit_should_send_a_notification
897 897 @request.session[:user_id] = 2
898 898 ActionMailer::Base.deliveries.clear
899 899 issue = Issue.find(1)
900 900 old_subject = issue.subject
901 901 new_subject = 'Subject modified by IssuesControllerTest#test_post_edit'
902 902
903 903 post :edit, :id => 1, :issue => {:subject => new_subject,
904 904 :priority_id => '6',
905 905 :category_id => '1' # no change
906 906 }
907 907 assert_equal 1, ActionMailer::Base.deliveries.size
908 908 end
909 909
910 910 def test_post_edit_with_invalid_spent_time
911 911 @request.session[:user_id] = 2
912 912 notes = 'Note added by IssuesControllerTest#test_post_edit_with_invalid_spent_time'
913 913
914 914 assert_no_difference('Journal.count') do
915 915 post :edit,
916 916 :id => 1,
917 917 :notes => notes,
918 918 :time_entry => {"comments"=>"", "activity_id"=>"", "hours"=>"2z"}
919 919 end
920 920 assert_response :success
921 921 assert_template 'edit'
922 922
923 923 assert_tag :textarea, :attributes => { :name => 'notes' },
924 924 :content => notes
925 925 assert_tag :input, :attributes => { :name => 'time_entry[hours]', :value => "2z" }
926 926 end
927 927
928 def test_post_edit_should_allow_fixed_version_to_be_set_to_a_subproject
929 issue = Issue.find(2)
930 @request.session[:user_id] = 2
931
932 post :edit,
933 :id => issue.id,
934 :issue => {
935 :fixed_version_id => 4
936 }
937
938 assert_response :redirect
939 issue.reload
940 assert_equal 4, issue.fixed_version_id
941 assert_not_equal issue.project_id, issue.fixed_version.project_id
942 end
943
928 944 def test_get_bulk_edit
929 945 @request.session[:user_id] = 2
930 946 get :bulk_edit, :ids => [1, 2]
931 947 assert_response :success
932 948 assert_template 'bulk_edit'
933 949 end
934 950
935 951 def test_bulk_edit
936 952 @request.session[:user_id] = 2
937 953 # update issues priority
938 954 post :bulk_edit, :ids => [1, 2], :priority_id => 7,
939 955 :assigned_to_id => '',
940 956 :custom_field_values => {'2' => ''},
941 957 :notes => 'Bulk editing'
942 958 assert_response 302
943 959 # check that the issues were updated
944 960 assert_equal [7, 7], Issue.find_all_by_id([1, 2]).collect {|i| i.priority.id}
945 961
946 962 issue = Issue.find(1)
947 963 journal = issue.journals.find(:first, :order => 'created_on DESC')
948 964 assert_equal '125', issue.custom_value_for(2).value
949 965 assert_equal 'Bulk editing', journal.notes
950 966 assert_equal 1, journal.details.size
951 967 end
952 968
953 969 def test_bullk_edit_should_send_a_notification
954 970 @request.session[:user_id] = 2
955 971 ActionMailer::Base.deliveries.clear
956 972 post(:bulk_edit,
957 973 {
958 974 :ids => [1, 2],
959 975 :priority_id => 7,
960 976 :assigned_to_id => '',
961 977 :custom_field_values => {'2' => ''},
962 978 :notes => 'Bulk editing'
963 979 })
964 980
965 981 assert_response 302
966 982 assert_equal 2, ActionMailer::Base.deliveries.size
967 983 end
968 984
969 985 def test_bulk_edit_status
970 986 @request.session[:user_id] = 2
971 987 # update issues priority
972 988 post :bulk_edit, :ids => [1, 2], :priority_id => '',
973 989 :assigned_to_id => '',
974 990 :status_id => '5',
975 991 :notes => 'Bulk editing status'
976 992 assert_response 302
977 993 issue = Issue.find(1)
978 994 assert issue.closed?
979 995 end
980 996
981 997 def test_bulk_edit_custom_field
982 998 @request.session[:user_id] = 2
983 999 # update issues priority
984 1000 post :bulk_edit, :ids => [1, 2], :priority_id => '',
985 1001 :assigned_to_id => '',
986 1002 :custom_field_values => {'2' => '777'},
987 1003 :notes => 'Bulk editing custom field'
988 1004 assert_response 302
989 1005
990 1006 issue = Issue.find(1)
991 1007 journal = issue.journals.find(:first, :order => 'created_on DESC')
992 1008 assert_equal '777', issue.custom_value_for(2).value
993 1009 assert_equal 1, journal.details.size
994 1010 assert_equal '125', journal.details.first.old_value
995 1011 assert_equal '777', journal.details.first.value
996 1012 end
997 1013
998 1014 def test_bulk_unassign
999 1015 assert_not_nil Issue.find(2).assigned_to
1000 1016 @request.session[:user_id] = 2
1001 1017 # unassign issues
1002 1018 post :bulk_edit, :ids => [1, 2], :notes => 'Bulk unassigning', :assigned_to_id => 'none'
1003 1019 assert_response 302
1004 1020 # check that the issues were updated
1005 1021 assert_nil Issue.find(2).assigned_to
1006 1022 end
1007 1023
1024 def test_post_bulk_edit_should_allow_fixed_version_to_be_set_to_a_subproject
1025 @request.session[:user_id] = 2
1026
1027 post :bulk_edit,
1028 :ids => [1,2],
1029 :fixed_version_id => 4
1030
1031 assert_response :redirect
1032 issues = Issue.find([1,2])
1033 issues.each do |issue|
1034 assert_equal 4, issue.fixed_version_id
1035 assert_not_equal issue.project_id, issue.fixed_version.project_id
1036 end
1037 end
1038
1008 1039 def test_move_routing
1009 1040 assert_routing(
1010 1041 {:method => :get, :path => '/issues/1/move'},
1011 1042 :controller => 'issues', :action => 'move', :id => '1'
1012 1043 )
1013 1044 assert_recognizes(
1014 1045 {:controller => 'issues', :action => 'move', :id => '1'},
1015 1046 {:method => :post, :path => '/issues/1/move'}
1016 1047 )
1017 1048 end
1018 1049
1019 1050 def test_move_one_issue_to_another_project
1020 1051 @request.session[:user_id] = 2
1021 1052 post :move, :id => 1, :new_project_id => 2
1022 1053 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1023 1054 assert_equal 2, Issue.find(1).project_id
1024 1055 end
1025 1056
1026 1057 def test_move_one_issue_to_another_project_should_follow_when_needed
1027 1058 @request.session[:user_id] = 2
1028 1059 post :move, :id => 1, :new_project_id => 2, :follow => '1'
1029 1060 assert_redirected_to '/issues/1'
1030 1061 end
1031 1062
1032 1063 def test_bulk_move_to_another_project
1033 1064 @request.session[:user_id] = 2
1034 1065 post :move, :ids => [1, 2], :new_project_id => 2
1035 1066 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1036 1067 # Issues moved to project 2
1037 1068 assert_equal 2, Issue.find(1).project_id
1038 1069 assert_equal 2, Issue.find(2).project_id
1039 1070 # No tracker change
1040 1071 assert_equal 1, Issue.find(1).tracker_id
1041 1072 assert_equal 2, Issue.find(2).tracker_id
1042 1073 end
1043 1074
1044 1075 def test_bulk_move_to_another_tracker
1045 1076 @request.session[:user_id] = 2
1046 1077 post :move, :ids => [1, 2], :new_tracker_id => 2
1047 1078 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1048 1079 assert_equal 2, Issue.find(1).tracker_id
1049 1080 assert_equal 2, Issue.find(2).tracker_id
1050 1081 end
1051 1082
1052 1083 def test_bulk_copy_to_another_project
1053 1084 @request.session[:user_id] = 2
1054 1085 assert_difference 'Issue.count', 2 do
1055 1086 assert_no_difference 'Project.find(1).issues.count' do
1056 1087 post :move, :ids => [1, 2], :new_project_id => 2, :copy_options => {:copy => '1'}
1057 1088 end
1058 1089 end
1059 1090 assert_redirected_to 'projects/ecookbook/issues'
1060 1091 end
1061 1092
1062 1093 context "#move via bulk copy" do
1063 1094 should "allow changing the issue's attributes" do
1064 1095 @request.session[:user_id] = 2
1065 1096 assert_difference 'Issue.count', 2 do
1066 1097 assert_no_difference 'Project.find(1).issues.count' do
1067 1098 post :move, :ids => [1, 2], :new_project_id => 2, :copy_options => {:copy => '1'}, :assigned_to_id => 4, :status_id => 3, :start_date => '2009-12-01', :due_date => '2009-12-31'
1068 1099 end
1069 1100 end
1070 1101
1071 1102 copied_issues = Issue.all(:limit => 2, :order => 'id desc', :conditions => {:project_id => 2})
1072 1103 assert_equal 2, copied_issues.size
1073 1104 copied_issues.each do |issue|
1074 1105 assert_equal 2, issue.project_id, "Project is incorrect"
1075 1106 assert_equal 4, issue.assigned_to_id, "Assigned to is incorrect"
1076 1107 assert_equal 3, issue.status_id, "Status is incorrect"
1077 1108 assert_equal '2009-12-01', issue.start_date.to_s, "Start date is incorrect"
1078 1109 assert_equal '2009-12-31', issue.due_date.to_s, "Due date is incorrect"
1079 1110 end
1080 1111 end
1081 1112 end
1082 1113
1083 1114 def test_copy_to_another_project_should_follow_when_needed
1084 1115 @request.session[:user_id] = 2
1085 1116 post :move, :ids => [1], :new_project_id => 2, :copy_options => {:copy => '1'}, :follow => '1'
1086 1117 issue = Issue.first(:order => 'id DESC')
1087 1118 assert_redirected_to :controller => 'issues', :action => 'show', :id => issue
1088 1119 end
1089 1120
1090 1121 def test_context_menu_one_issue
1091 1122 @request.session[:user_id] = 2
1092 1123 get :context_menu, :ids => [1]
1093 1124 assert_response :success
1094 1125 assert_template 'context_menu'
1095 1126 assert_tag :tag => 'a', :content => 'Edit',
1096 1127 :attributes => { :href => '/issues/1/edit',
1097 1128 :class => 'icon-edit' }
1098 1129 assert_tag :tag => 'a', :content => 'Closed',
1099 1130 :attributes => { :href => '/issues/1/edit?issue%5Bstatus_id%5D=5',
1100 1131 :class => '' }
1101 1132 assert_tag :tag => 'a', :content => 'Immediate',
1102 1133 :attributes => { :href => '/issues/bulk_edit?ids%5B%5D=1&amp;priority_id=8',
1103 1134 :class => '' }
1135 # Versions
1136 assert_tag :tag => 'a', :content => '2.0',
1137 :attributes => { :href => '/issues/bulk_edit?fixed_version_id=3&amp;ids%5B%5D=1',
1138 :class => '' }
1139 assert_tag :tag => 'a', :content => 'eCookbook Subproject 1 - 2.0',
1140 :attributes => { :href => '/issues/bulk_edit?fixed_version_id=4&amp;ids%5B%5D=1',
1141 :class => '' }
1142
1104 1143 assert_tag :tag => 'a', :content => 'Dave Lopper',
1105 1144 :attributes => { :href => '/issues/bulk_edit?assigned_to_id=3&amp;ids%5B%5D=1',
1106 1145 :class => '' }
1107 1146 assert_tag :tag => 'a', :content => 'Copy',
1108 1147 :attributes => { :href => '/projects/ecookbook/issues/1/copy',
1109 1148 :class => 'icon-copy' }
1110 1149 assert_tag :tag => 'a', :content => 'Move',
1111 1150 :attributes => { :href => '/issues/move?ids%5B%5D=1',
1112 1151 :class => 'icon-move' }
1113 1152 assert_tag :tag => 'a', :content => 'Delete',
1114 1153 :attributes => { :href => '/issues/destroy?ids%5B%5D=1',
1115 1154 :class => 'icon-del' }
1116 1155 end
1117 1156
1118 1157 def test_context_menu_one_issue_by_anonymous
1119 1158 get :context_menu, :ids => [1]
1120 1159 assert_response :success
1121 1160 assert_template 'context_menu'
1122 1161 assert_tag :tag => 'a', :content => 'Delete',
1123 1162 :attributes => { :href => '#',
1124 1163 :class => 'icon-del disabled' }
1125 1164 end
1126 1165
1127 1166 def test_context_menu_multiple_issues_of_same_project
1128 1167 @request.session[:user_id] = 2
1129 1168 get :context_menu, :ids => [1, 2]
1130 1169 assert_response :success
1131 1170 assert_template 'context_menu'
1132 1171 assert_tag :tag => 'a', :content => 'Edit',
1133 1172 :attributes => { :href => '/issues/bulk_edit?ids%5B%5D=1&amp;ids%5B%5D=2',
1134 1173 :class => 'icon-edit' }
1135 1174 assert_tag :tag => 'a', :content => 'Immediate',
1136 1175 :attributes => { :href => '/issues/bulk_edit?ids%5B%5D=1&amp;ids%5B%5D=2&amp;priority_id=8',
1137 1176 :class => '' }
1138 1177 assert_tag :tag => 'a', :content => 'Dave Lopper',
1139 1178 :attributes => { :href => '/issues/bulk_edit?assigned_to_id=3&amp;ids%5B%5D=1&amp;ids%5B%5D=2',
1140 1179 :class => '' }
1141 1180 assert_tag :tag => 'a', :content => 'Copy',
1142 1181 :attributes => { :href => '/issues/move?copy_options%5Bcopy%5D=t&amp;ids%5B%5D=1&amp;ids%5B%5D=2',
1143 1182 :class => 'icon-copy' }
1144 1183 assert_tag :tag => 'a', :content => 'Move',
1145 1184 :attributes => { :href => '/issues/move?ids%5B%5D=1&amp;ids%5B%5D=2',
1146 1185 :class => 'icon-move' }
1147 1186 assert_tag :tag => 'a', :content => 'Delete',
1148 1187 :attributes => { :href => '/issues/destroy?ids%5B%5D=1&amp;ids%5B%5D=2',
1149 1188 :class => 'icon-del' }
1150 1189 end
1151 1190
1152 1191 def test_context_menu_multiple_issues_of_different_project
1153 1192 @request.session[:user_id] = 2
1154 1193 get :context_menu, :ids => [1, 2, 4]
1155 1194 assert_response :success
1156 1195 assert_template 'context_menu'
1157 1196 assert_tag :tag => 'a', :content => 'Delete',
1158 1197 :attributes => { :href => '#',
1159 1198 :class => 'icon-del disabled' }
1160 1199 end
1161 1200
1162 1201 def test_destroy_routing
1163 1202 assert_recognizes( #TODO: use DELETE on issue URI (need to change forms)
1164 1203 {:controller => 'issues', :action => 'destroy', :id => '1'},
1165 1204 {:method => :post, :path => '/issues/1/destroy'}
1166 1205 )
1167 1206 end
1168 1207
1169 1208 def test_destroy_issue_with_no_time_entries
1170 1209 assert_nil TimeEntry.find_by_issue_id(2)
1171 1210 @request.session[:user_id] = 2
1172 1211 post :destroy, :id => 2
1173 1212 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1174 1213 assert_nil Issue.find_by_id(2)
1175 1214 end
1176 1215
1177 1216 def test_destroy_issues_with_time_entries
1178 1217 @request.session[:user_id] = 2
1179 1218 post :destroy, :ids => [1, 3]
1180 1219 assert_response :success
1181 1220 assert_template 'destroy'
1182 1221 assert_not_nil assigns(:hours)
1183 1222 assert Issue.find_by_id(1) && Issue.find_by_id(3)
1184 1223 end
1185 1224
1186 1225 def test_destroy_issues_and_destroy_time_entries
1187 1226 @request.session[:user_id] = 2
1188 1227 post :destroy, :ids => [1, 3], :todo => 'destroy'
1189 1228 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1190 1229 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
1191 1230 assert_nil TimeEntry.find_by_id([1, 2])
1192 1231 end
1193 1232
1194 1233 def test_destroy_issues_and_assign_time_entries_to_project
1195 1234 @request.session[:user_id] = 2
1196 1235 post :destroy, :ids => [1, 3], :todo => 'nullify'
1197 1236 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1198 1237 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
1199 1238 assert_nil TimeEntry.find(1).issue_id
1200 1239 assert_nil TimeEntry.find(2).issue_id
1201 1240 end
1202 1241
1203 1242 def test_destroy_issues_and_reassign_time_entries_to_another_issue
1204 1243 @request.session[:user_id] = 2
1205 1244 post :destroy, :ids => [1, 3], :todo => 'reassign', :reassign_to_id => 2
1206 1245 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
1207 1246 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
1208 1247 assert_equal 2, TimeEntry.find(1).issue_id
1209 1248 assert_equal 2, TimeEntry.find(2).issue_id
1210 1249 end
1211 1250
1212 1251 def test_default_search_scope
1213 1252 get :index
1214 1253 assert_tag :div, :attributes => {:id => 'quick-search'},
1215 1254 :child => {:tag => 'form',
1216 1255 :child => {:tag => 'input', :attributes => {:name => 'issues', :type => 'hidden', :value => '1'}}}
1217 1256 end
1218 1257 end
@@ -1,782 +1,800
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 File.dirname(__FILE__) + '/../test_helper'
19 19 require 'projects_controller'
20 20
21 21 # Re-raise errors caught by the controller.
22 22 class ProjectsController; def rescue_action(e) raise e end; end
23 23
24 24 class ProjectsControllerTest < ActionController::TestCase
25 25 fixtures :projects, :versions, :users, :roles, :members, :member_roles, :issues, :journals, :journal_details,
26 26 :trackers, :projects_trackers, :issue_statuses, :enabled_modules, :enumerations, :boards, :messages,
27 27 :attachments, :custom_fields, :custom_values, :time_entries
28 28
29 29 def setup
30 30 @controller = ProjectsController.new
31 31 @request = ActionController::TestRequest.new
32 32 @response = ActionController::TestResponse.new
33 33 @request.session[:user_id] = nil
34 34 Setting.default_language = 'en'
35 35 end
36 36
37 37 def test_index_routing
38 38 assert_routing(
39 39 {:method => :get, :path => '/projects'},
40 40 :controller => 'projects', :action => 'index'
41 41 )
42 42 end
43 43
44 44 def test_index
45 45 get :index
46 46 assert_response :success
47 47 assert_template 'index'
48 48 assert_not_nil assigns(:projects)
49 49
50 50 assert_tag :ul, :child => {:tag => 'li',
51 51 :descendant => {:tag => 'a', :content => 'eCookbook'},
52 52 :child => { :tag => 'ul',
53 53 :descendant => { :tag => 'a',
54 54 :content => 'Child of private child'
55 55 }
56 56 }
57 57 }
58 58
59 59 assert_no_tag :a, :content => /Private child of eCookbook/
60 60 end
61 61
62 62 def test_index_atom_routing
63 63 assert_routing(
64 64 {:method => :get, :path => '/projects.atom'},
65 65 :controller => 'projects', :action => 'index', :format => 'atom'
66 66 )
67 67 end
68 68
69 69 def test_index_atom
70 70 get :index, :format => 'atom'
71 71 assert_response :success
72 72 assert_template 'common/feed.atom.rxml'
73 73 assert_select 'feed>title', :text => 'Redmine: Latest projects'
74 74 assert_select 'feed>entry', :count => Project.count(:conditions => Project.visible_by(User.current))
75 75 end
76 76
77 77 def test_add_routing
78 78 assert_routing(
79 79 {:method => :get, :path => '/projects/new'},
80 80 :controller => 'projects', :action => 'add'
81 81 )
82 82 assert_recognizes(
83 83 {:controller => 'projects', :action => 'add'},
84 84 {:method => :post, :path => '/projects/new'}
85 85 )
86 86 assert_recognizes(
87 87 {:controller => 'projects', :action => 'add'},
88 88 {:method => :post, :path => '/projects'}
89 89 )
90 90 end
91 91
92 92 def test_get_add
93 93 @request.session[:user_id] = 1
94 94 get :add
95 95 assert_response :success
96 96 assert_template 'add'
97 97 end
98 98
99 99 def test_get_add_by_non_admin
100 100 @request.session[:user_id] = 2
101 101 get :add
102 102 assert_response :success
103 103 assert_template 'add'
104 104 end
105 105
106 106 def test_post_add
107 107 @request.session[:user_id] = 1
108 108 post :add, :project => { :name => "blog",
109 109 :description => "weblog",
110 110 :identifier => "blog",
111 111 :is_public => 1,
112 112 :custom_field_values => { '3' => 'Beta' }
113 113 }
114 114 assert_redirected_to '/projects/blog/settings'
115 115
116 116 project = Project.find_by_name('blog')
117 117 assert_kind_of Project, project
118 118 assert_equal 'weblog', project.description
119 119 assert_equal true, project.is_public?
120 120 assert_nil project.parent
121 121 end
122 122
123 123 def test_post_add_subproject
124 124 @request.session[:user_id] = 1
125 125 post :add, :project => { :name => "blog",
126 126 :description => "weblog",
127 127 :identifier => "blog",
128 128 :is_public => 1,
129 129 :custom_field_values => { '3' => 'Beta' },
130 130 :parent_id => 1
131 131 }
132 132 assert_redirected_to '/projects/blog/settings'
133 133
134 134 project = Project.find_by_name('blog')
135 135 assert_kind_of Project, project
136 136 assert_equal Project.find(1), project.parent
137 137 end
138 138
139 139 def test_post_add_by_non_admin
140 140 @request.session[:user_id] = 2
141 141 post :add, :project => { :name => "blog",
142 142 :description => "weblog",
143 143 :identifier => "blog",
144 144 :is_public => 1,
145 145 :custom_field_values => { '3' => 'Beta' }
146 146 }
147 147 assert_redirected_to '/projects/blog/settings'
148 148
149 149 project = Project.find_by_name('blog')
150 150 assert_kind_of Project, project
151 151 assert_equal 'weblog', project.description
152 152 assert_equal true, project.is_public?
153 153
154 154 # User should be added as a project member
155 155 assert User.find(2).member_of?(project)
156 156 assert_equal 1, project.members.size
157 157 end
158 158
159 159 def test_show_routing
160 160 assert_routing(
161 161 {:method => :get, :path => '/projects/test'},
162 162 :controller => 'projects', :action => 'show', :id => 'test'
163 163 )
164 164 end
165 165
166 166 def test_show_by_id
167 167 get :show, :id => 1
168 168 assert_response :success
169 169 assert_template 'show'
170 170 assert_not_nil assigns(:project)
171 171 end
172 172
173 173 def test_show_by_identifier
174 174 get :show, :id => 'ecookbook'
175 175 assert_response :success
176 176 assert_template 'show'
177 177 assert_not_nil assigns(:project)
178 178 assert_equal Project.find_by_identifier('ecookbook'), assigns(:project)
179 179 end
180 180
181 181 def test_show_should_not_fail_when_custom_values_are_nil
182 182 project = Project.find_by_identifier('ecookbook')
183 183 project.custom_values.first.update_attribute(:value, nil)
184 184 get :show, :id => 'ecookbook'
185 185 assert_response :success
186 186 assert_template 'show'
187 187 assert_not_nil assigns(:project)
188 188 assert_equal Project.find_by_identifier('ecookbook'), assigns(:project)
189 189 end
190 190
191 191 def test_private_subprojects_hidden
192 192 get :show, :id => 'ecookbook'
193 193 assert_response :success
194 194 assert_template 'show'
195 195 assert_no_tag :tag => 'a', :content => /Private child/
196 196 end
197 197
198 198 def test_private_subprojects_visible
199 199 @request.session[:user_id] = 2 # manager who is a member of the private subproject
200 200 get :show, :id => 'ecookbook'
201 201 assert_response :success
202 202 assert_template 'show'
203 203 assert_tag :tag => 'a', :content => /Private child/
204 204 end
205 205
206 206 def test_settings_routing
207 207 assert_routing(
208 208 {:method => :get, :path => '/projects/4223/settings'},
209 209 :controller => 'projects', :action => 'settings', :id => '4223'
210 210 )
211 211 assert_routing(
212 212 {:method => :get, :path => '/projects/4223/settings/members'},
213 213 :controller => 'projects', :action => 'settings', :id => '4223', :tab => 'members'
214 214 )
215 215 end
216 216
217 217 def test_settings
218 218 @request.session[:user_id] = 2 # manager
219 219 get :settings, :id => 1
220 220 assert_response :success
221 221 assert_template 'settings'
222 222 end
223 223
224 224 def test_edit
225 225 @request.session[:user_id] = 2 # manager
226 226 post :edit, :id => 1, :project => {:name => 'Test changed name',
227 227 :issue_custom_field_ids => ['']}
228 228 assert_redirected_to 'projects/ecookbook/settings'
229 229 project = Project.find(1)
230 230 assert_equal 'Test changed name', project.name
231 231 end
232 232
233 233 def test_add_version_routing
234 234 assert_routing(
235 235 {:method => :get, :path => 'projects/64/versions/new'},
236 236 :controller => 'projects', :action => 'add_version', :id => '64'
237 237 )
238 238 assert_routing(
239 239 #TODO: use PUT
240 240 {:method => :post, :path => 'projects/64/versions/new'},
241 241 :controller => 'projects', :action => 'add_version', :id => '64'
242 242 )
243 243 end
244 244
245 245 def test_add_issue_category_routing
246 246 assert_routing(
247 247 {:method => :get, :path => 'projects/test/categories/new'},
248 248 :controller => 'projects', :action => 'add_issue_category', :id => 'test'
249 249 )
250 250 assert_routing(
251 251 #TODO: use PUT and update form
252 252 {:method => :post, :path => 'projects/64/categories/new'},
253 253 :controller => 'projects', :action => 'add_issue_category', :id => '64'
254 254 )
255 255 end
256 256
257 257 def test_destroy_routing
258 258 assert_routing(
259 259 {:method => :get, :path => '/projects/567/destroy'},
260 260 :controller => 'projects', :action => 'destroy', :id => '567'
261 261 )
262 262 assert_routing(
263 263 #TODO: use DELETE and update form
264 264 {:method => :post, :path => 'projects/64/destroy'},
265 265 :controller => 'projects', :action => 'destroy', :id => '64'
266 266 )
267 267 end
268 268
269 269 def test_get_destroy
270 270 @request.session[:user_id] = 1 # admin
271 271 get :destroy, :id => 1
272 272 assert_response :success
273 273 assert_template 'destroy'
274 274 assert_not_nil Project.find_by_id(1)
275 275 end
276 276
277 277 def test_post_destroy
278 278 @request.session[:user_id] = 1 # admin
279 279 post :destroy, :id => 1, :confirm => 1
280 280 assert_redirected_to 'admin/projects'
281 281 assert_nil Project.find_by_id(1)
282 282 end
283 283
284 284 def test_add_file
285 285 set_tmp_attachments_directory
286 286 @request.session[:user_id] = 2
287 287 Setting.notified_events = ['file_added']
288 288 ActionMailer::Base.deliveries.clear
289 289
290 290 assert_difference 'Attachment.count' do
291 291 post :add_file, :id => 1, :version_id => '',
292 292 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}}
293 293 end
294 294 assert_redirected_to 'projects/ecookbook/files'
295 295 a = Attachment.find(:first, :order => 'created_on DESC')
296 296 assert_equal 'testfile.txt', a.filename
297 297 assert_equal Project.find(1), a.container
298 298
299 299 mail = ActionMailer::Base.deliveries.last
300 300 assert_kind_of TMail::Mail, mail
301 301 assert_equal "[eCookbook] New file", mail.subject
302 302 assert mail.body.include?('testfile.txt')
303 303 end
304 304
305 305 def test_add_file_routing
306 306 assert_routing(
307 307 {:method => :get, :path => '/projects/33/files/new'},
308 308 :controller => 'projects', :action => 'add_file', :id => '33'
309 309 )
310 310 assert_routing(
311 311 {:method => :post, :path => '/projects/33/files/new'},
312 312 :controller => 'projects', :action => 'add_file', :id => '33'
313 313 )
314 314 end
315 315
316 316 def test_add_version_file
317 317 set_tmp_attachments_directory
318 318 @request.session[:user_id] = 2
319 319 Setting.notified_events = ['file_added']
320 320
321 321 assert_difference 'Attachment.count' do
322 322 post :add_file, :id => 1, :version_id => '2',
323 323 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}}
324 324 end
325 325 assert_redirected_to 'projects/ecookbook/files'
326 326 a = Attachment.find(:first, :order => 'created_on DESC')
327 327 assert_equal 'testfile.txt', a.filename
328 328 assert_equal Version.find(2), a.container
329 329 end
330 330
331 331 def test_list_files
332 332 get :list_files, :id => 1
333 333 assert_response :success
334 334 assert_template 'list_files'
335 335 assert_not_nil assigns(:containers)
336 336
337 337 # file attached to the project
338 338 assert_tag :a, :content => 'project_file.zip',
339 339 :attributes => { :href => '/attachments/download/8/project_file.zip' }
340 340
341 341 # file attached to a project's version
342 342 assert_tag :a, :content => 'version_file.zip',
343 343 :attributes => { :href => '/attachments/download/9/version_file.zip' }
344 344 end
345 345
346 346 def test_list_files_routing
347 347 assert_routing(
348 348 {:method => :get, :path => '/projects/33/files'},
349 349 :controller => 'projects', :action => 'list_files', :id => '33'
350 350 )
351 351 end
352 352
353 353 def test_changelog_routing
354 354 assert_routing(
355 355 {:method => :get, :path => '/projects/44/changelog'},
356 356 :controller => 'projects', :action => 'changelog', :id => '44'
357 357 )
358 358 end
359 359
360 360 def test_changelog
361 361 get :changelog, :id => 1
362 362 assert_response :success
363 363 assert_template 'changelog'
364 364 assert_not_nil assigns(:versions)
365 365 end
366 366
367 def test_changelog_showing_subprojects_versions
368 get :changelog, :id => 1, :with_subprojects => 1
369 assert_response :success
370 assert_template 'changelog'
371 assert_not_nil assigns(:versions)
372 # Version on subproject appears
373 assert assigns(:versions).include?(Version.find(4))
374 end
375
367 376 def test_roadmap_routing
368 377 assert_routing(
369 378 {:method => :get, :path => 'projects/33/roadmap'},
370 379 :controller => 'projects', :action => 'roadmap', :id => '33'
371 380 )
372 381 end
373 382
374 383 def test_roadmap
375 384 get :roadmap, :id => 1
376 385 assert_response :success
377 386 assert_template 'roadmap'
378 387 assert_not_nil assigns(:versions)
379 388 # Version with no date set appears
380 389 assert assigns(:versions).include?(Version.find(3))
381 390 # Completed version doesn't appear
382 391 assert !assigns(:versions).include?(Version.find(1))
383 392 end
384 393
385 394 def test_roadmap_with_completed_versions
386 395 get :roadmap, :id => 1, :completed => 1
387 396 assert_response :success
388 397 assert_template 'roadmap'
389 398 assert_not_nil assigns(:versions)
390 399 # Version with no date set appears
391 400 assert assigns(:versions).include?(Version.find(3))
392 401 # Completed version appears
393 402 assert assigns(:versions).include?(Version.find(1))
394 403 end
404
405 def test_roadmap_showing_subprojects_versions
406 get :roadmap, :id => 1, :with_subprojects => 1
407 assert_response :success
408 assert_template 'roadmap'
409 assert_not_nil assigns(:versions)
410 # Version on subproject appears
411 assert assigns(:versions).include?(Version.find(4))
412 end
395 413
396 414 def test_project_activity_routing
397 415 assert_routing(
398 416 {:method => :get, :path => '/projects/1/activity'},
399 417 :controller => 'projects', :action => 'activity', :id => '1'
400 418 )
401 419 end
402 420
403 421 def test_project_activity_atom_routing
404 422 assert_routing(
405 423 {:method => :get, :path => '/projects/1/activity.atom'},
406 424 :controller => 'projects', :action => 'activity', :id => '1', :format => 'atom'
407 425 )
408 426 end
409 427
410 428 def test_project_activity
411 429 get :activity, :id => 1, :with_subprojects => 0
412 430 assert_response :success
413 431 assert_template 'activity'
414 432 assert_not_nil assigns(:events_by_day)
415 433
416 434 assert_tag :tag => "h3",
417 435 :content => /#{2.days.ago.to_date.day}/,
418 436 :sibling => { :tag => "dl",
419 437 :child => { :tag => "dt",
420 438 :attributes => { :class => /issue-edit/ },
421 439 :child => { :tag => "a",
422 440 :content => /(#{IssueStatus.find(2).name})/,
423 441 }
424 442 }
425 443 }
426 444 end
427 445
428 446 def test_previous_project_activity
429 447 get :activity, :id => 1, :from => 3.days.ago.to_date
430 448 assert_response :success
431 449 assert_template 'activity'
432 450 assert_not_nil assigns(:events_by_day)
433 451
434 452 assert_tag :tag => "h3",
435 453 :content => /#{3.day.ago.to_date.day}/,
436 454 :sibling => { :tag => "dl",
437 455 :child => { :tag => "dt",
438 456 :attributes => { :class => /issue/ },
439 457 :child => { :tag => "a",
440 458 :content => /#{Issue.find(1).subject}/,
441 459 }
442 460 }
443 461 }
444 462 end
445 463
446 464 def test_global_activity_routing
447 465 assert_routing({:method => :get, :path => '/activity'}, :controller => 'projects', :action => 'activity', :id => nil)
448 466 end
449 467
450 468 def test_global_activity
451 469 get :activity
452 470 assert_response :success
453 471 assert_template 'activity'
454 472 assert_not_nil assigns(:events_by_day)
455 473
456 474 assert_tag :tag => "h3",
457 475 :content => /#{5.day.ago.to_date.day}/,
458 476 :sibling => { :tag => "dl",
459 477 :child => { :tag => "dt",
460 478 :attributes => { :class => /issue/ },
461 479 :child => { :tag => "a",
462 480 :content => /#{Issue.find(5).subject}/,
463 481 }
464 482 }
465 483 }
466 484 end
467 485
468 486 def test_user_activity
469 487 get :activity, :user_id => 2
470 488 assert_response :success
471 489 assert_template 'activity'
472 490 assert_not_nil assigns(:events_by_day)
473 491
474 492 assert_tag :tag => "h3",
475 493 :content => /#{3.day.ago.to_date.day}/,
476 494 :sibling => { :tag => "dl",
477 495 :child => { :tag => "dt",
478 496 :attributes => { :class => /issue/ },
479 497 :child => { :tag => "a",
480 498 :content => /#{Issue.find(1).subject}/,
481 499 }
482 500 }
483 501 }
484 502 end
485 503
486 504 def test_global_activity_atom_routing
487 505 assert_routing({:method => :get, :path => '/activity.atom'}, :controller => 'projects', :action => 'activity', :id => nil, :format => 'atom')
488 506 end
489 507
490 508 def test_activity_atom_feed
491 509 get :activity, :format => 'atom'
492 510 assert_response :success
493 511 assert_template 'common/feed.atom.rxml'
494 512 end
495 513
496 514 def test_archive_routing
497 515 assert_routing(
498 516 #TODO: use PUT to project path and modify form
499 517 {:method => :post, :path => 'projects/64/archive'},
500 518 :controller => 'projects', :action => 'archive', :id => '64'
501 519 )
502 520 end
503 521
504 522 def test_archive
505 523 @request.session[:user_id] = 1 # admin
506 524 post :archive, :id => 1
507 525 assert_redirected_to 'admin/projects'
508 526 assert !Project.find(1).active?
509 527 end
510 528
511 529 def test_unarchive_routing
512 530 assert_routing(
513 531 #TODO: use PUT to project path and modify form
514 532 {:method => :post, :path => '/projects/567/unarchive'},
515 533 :controller => 'projects', :action => 'unarchive', :id => '567'
516 534 )
517 535 end
518 536
519 537 def test_unarchive
520 538 @request.session[:user_id] = 1 # admin
521 539 Project.find(1).archive
522 540 post :unarchive, :id => 1
523 541 assert_redirected_to 'admin/projects'
524 542 assert Project.find(1).active?
525 543 end
526 544
527 545 def test_project_breadcrumbs_should_be_limited_to_3_ancestors
528 546 CustomField.delete_all
529 547 parent = nil
530 548 6.times do |i|
531 549 p = Project.create!(:name => "Breadcrumbs #{i}", :identifier => "breadcrumbs-#{i}")
532 550 p.set_parent!(parent)
533 551 get :show, :id => p
534 552 assert_tag :h1, :parent => { :attributes => {:id => 'header'}},
535 553 :children => { :count => [i, 3].min,
536 554 :only => { :tag => 'a' } }
537 555
538 556 parent = p
539 557 end
540 558 end
541 559
542 560 def test_copy_with_project
543 561 @request.session[:user_id] = 1 # admin
544 562 get :copy, :id => 1
545 563 assert_response :success
546 564 assert_template 'copy'
547 565 assert assigns(:project)
548 566 assert_equal Project.find(1).description, assigns(:project).description
549 567 assert_nil assigns(:project).id
550 568 end
551 569
552 570 def test_copy_without_project
553 571 @request.session[:user_id] = 1 # admin
554 572 get :copy
555 573 assert_response :redirect
556 574 assert_redirected_to :controller => 'admin', :action => 'projects'
557 575 end
558 576
559 577 def test_jump_should_redirect_to_active_tab
560 578 get :show, :id => 1, :jump => 'issues'
561 579 assert_redirected_to 'projects/ecookbook/issues'
562 580 end
563 581
564 582 def test_jump_should_not_redirect_to_inactive_tab
565 583 get :show, :id => 3, :jump => 'documents'
566 584 assert_response :success
567 585 assert_template 'show'
568 586 end
569 587
570 588 def test_jump_should_not_redirect_to_unknown_tab
571 589 get :show, :id => 3, :jump => 'foobar'
572 590 assert_response :success
573 591 assert_template 'show'
574 592 end
575 593
576 594 def test_reset_activities_routing
577 595 assert_routing({:method => :delete, :path => 'projects/64/reset_activities'},
578 596 :controller => 'projects', :action => 'reset_activities', :id => '64')
579 597 end
580 598
581 599 def test_reset_activities
582 600 @request.session[:user_id] = 2 # manager
583 601 project_activity = TimeEntryActivity.new({
584 602 :name => 'Project Specific',
585 603 :parent => TimeEntryActivity.find(:first),
586 604 :project => Project.find(1),
587 605 :active => true
588 606 })
589 607 assert project_activity.save
590 608 project_activity_two = TimeEntryActivity.new({
591 609 :name => 'Project Specific Two',
592 610 :parent => TimeEntryActivity.find(:last),
593 611 :project => Project.find(1),
594 612 :active => true
595 613 })
596 614 assert project_activity_two.save
597 615
598 616 delete :reset_activities, :id => 1
599 617 assert_response :redirect
600 618 assert_redirected_to 'projects/ecookbook/settings/activities'
601 619
602 620 assert_nil TimeEntryActivity.find_by_id(project_activity.id)
603 621 assert_nil TimeEntryActivity.find_by_id(project_activity_two.id)
604 622 end
605 623
606 624 def test_reset_activities_should_reassign_time_entries_back_to_the_system_activity
607 625 @request.session[:user_id] = 2 # manager
608 626 project_activity = TimeEntryActivity.new({
609 627 :name => 'Project Specific Design',
610 628 :parent => TimeEntryActivity.find(9),
611 629 :project => Project.find(1),
612 630 :active => true
613 631 })
614 632 assert project_activity.save
615 633 assert TimeEntry.update_all("activity_id = '#{project_activity.id}'", ["project_id = ? AND activity_id = ?", 1, 9])
616 634 assert 3, TimeEntry.find_all_by_activity_id_and_project_id(project_activity.id, 1).size
617 635
618 636 delete :reset_activities, :id => 1
619 637 assert_response :redirect
620 638 assert_redirected_to 'projects/ecookbook/settings/activities'
621 639
622 640 assert_nil TimeEntryActivity.find_by_id(project_activity.id)
623 641 assert_equal 0, TimeEntry.find_all_by_activity_id_and_project_id(project_activity.id, 1).size, "TimeEntries still assigned to project specific activity"
624 642 assert_equal 3, TimeEntry.find_all_by_activity_id_and_project_id(9, 1).size, "TimeEntries still assigned to project specific activity"
625 643 end
626 644
627 645 def test_save_activities_routing
628 646 assert_routing({:method => :post, :path => 'projects/64/activities/save'},
629 647 :controller => 'projects', :action => 'save_activities', :id => '64')
630 648 end
631 649
632 650 def test_save_activities_to_override_system_activities
633 651 @request.session[:user_id] = 2 # manager
634 652 billable_field = TimeEntryActivityCustomField.find_by_name("Billable")
635 653
636 654 post :save_activities, :id => 1, :enumerations => {
637 655 "9"=> {"parent_id"=>"9", "custom_field_values"=>{"7" => "1"}, "active"=>"0"}, # Design, De-activate
638 656 "10"=> {"parent_id"=>"10", "custom_field_values"=>{"7"=>"0"}, "active"=>"1"}, # Development, Change custom value
639 657 "14"=>{"parent_id"=>"14", "custom_field_values"=>{"7"=>"1"}, "active"=>"1"}, # Inactive Activity, Activate with custom value
640 658 "11"=>{"parent_id"=>"11", "custom_field_values"=>{"7"=>"1"}, "active"=>"1"} # QA, no changes
641 659 }
642 660
643 661 assert_response :redirect
644 662 assert_redirected_to 'projects/ecookbook/settings/activities'
645 663
646 664 # Created project specific activities...
647 665 project = Project.find('ecookbook')
648 666
649 667 # ... Design
650 668 design = project.time_entry_activities.find_by_name("Design")
651 669 assert design, "Project activity not found"
652 670
653 671 assert_equal 9, design.parent_id # Relate to the system activity
654 672 assert_not_equal design.parent.id, design.id # Different records
655 673 assert_equal design.parent.name, design.name # Same name
656 674 assert !design.active?
657 675
658 676 # ... Development
659 677 development = project.time_entry_activities.find_by_name("Development")
660 678 assert development, "Project activity not found"
661 679
662 680 assert_equal 10, development.parent_id # Relate to the system activity
663 681 assert_not_equal development.parent.id, development.id # Different records
664 682 assert_equal development.parent.name, development.name # Same name
665 683 assert development.active?
666 684 assert_equal "0", development.custom_value_for(billable_field).value
667 685
668 686 # ... Inactive Activity
669 687 previously_inactive = project.time_entry_activities.find_by_name("Inactive Activity")
670 688 assert previously_inactive, "Project activity not found"
671 689
672 690 assert_equal 14, previously_inactive.parent_id # Relate to the system activity
673 691 assert_not_equal previously_inactive.parent.id, previously_inactive.id # Different records
674 692 assert_equal previously_inactive.parent.name, previously_inactive.name # Same name
675 693 assert previously_inactive.active?
676 694 assert_equal "1", previously_inactive.custom_value_for(billable_field).value
677 695
678 696 # ... QA
679 697 assert_equal nil, project.time_entry_activities.find_by_name("QA"), "Custom QA activity created when it wasn't modified"
680 698 end
681 699
682 700 def test_save_activities_will_update_project_specific_activities
683 701 @request.session[:user_id] = 2 # manager
684 702
685 703 project_activity = TimeEntryActivity.new({
686 704 :name => 'Project Specific',
687 705 :parent => TimeEntryActivity.find(:first),
688 706 :project => Project.find(1),
689 707 :active => true
690 708 })
691 709 assert project_activity.save
692 710 project_activity_two = TimeEntryActivity.new({
693 711 :name => 'Project Specific Two',
694 712 :parent => TimeEntryActivity.find(:last),
695 713 :project => Project.find(1),
696 714 :active => true
697 715 })
698 716 assert project_activity_two.save
699 717
700 718
701 719 post :save_activities, :id => 1, :enumerations => {
702 720 project_activity.id => {"custom_field_values"=>{"7" => "1"}, "active"=>"0"}, # De-activate
703 721 project_activity_two.id => {"custom_field_values"=>{"7" => "1"}, "active"=>"0"} # De-activate
704 722 }
705 723
706 724 assert_response :redirect
707 725 assert_redirected_to 'projects/ecookbook/settings/activities'
708 726
709 727 # Created project specific activities...
710 728 project = Project.find('ecookbook')
711 729 assert_equal 2, project.time_entry_activities.count
712 730
713 731 activity_one = project.time_entry_activities.find_by_name(project_activity.name)
714 732 assert activity_one, "Project activity not found"
715 733 assert_equal project_activity.id, activity_one.id
716 734 assert !activity_one.active?
717 735
718 736 activity_two = project.time_entry_activities.find_by_name(project_activity_two.name)
719 737 assert activity_two, "Project activity not found"
720 738 assert_equal project_activity_two.id, activity_two.id
721 739 assert !activity_two.active?
722 740 end
723 741
724 742 def test_save_activities_when_creating_new_activities_will_convert_existing_data
725 743 assert_equal 3, TimeEntry.find_all_by_activity_id_and_project_id(9, 1).size
726 744
727 745 @request.session[:user_id] = 2 # manager
728 746 post :save_activities, :id => 1, :enumerations => {
729 747 "9"=> {"parent_id"=>"9", "custom_field_values"=>{"7" => "1"}, "active"=>"0"} # Design, De-activate
730 748 }
731 749 assert_response :redirect
732 750
733 751 # No more TimeEntries using the system activity
734 752 assert_equal 0, TimeEntry.find_all_by_activity_id_and_project_id(9, 1).size, "Time Entries still assigned to system activities"
735 753 # All TimeEntries using project activity
736 754 project_specific_activity = TimeEntryActivity.find_by_parent_id_and_project_id(9, 1)
737 755 assert_equal 3, TimeEntry.find_all_by_activity_id_and_project_id(project_specific_activity.id, 1).size, "No Time Entries assigned to the project activity"
738 756 end
739 757
740 758 def test_save_activities_when_creating_new_activities_will_not_convert_existing_data_if_an_exception_is_raised
741 759 # TODO: Need to cause an exception on create but these tests
742 760 # aren't setup for mocking. Just create a record now so the
743 761 # second one is a dupicate
744 762 parent = TimeEntryActivity.find(9)
745 763 TimeEntryActivity.create!({:name => parent.name, :project_id => 1, :position => parent.position, :active => true})
746 764 TimeEntry.create!({:project_id => 1, :hours => 1.0, :user => User.find(1), :issue_id => 3, :activity_id => 10, :spent_on => '2009-01-01'})
747 765
748 766 assert_equal 3, TimeEntry.find_all_by_activity_id_and_project_id(9, 1).size
749 767 assert_equal 1, TimeEntry.find_all_by_activity_id_and_project_id(10, 1).size
750 768
751 769 @request.session[:user_id] = 2 # manager
752 770 post :save_activities, :id => 1, :enumerations => {
753 771 "9"=> {"parent_id"=>"9", "custom_field_values"=>{"7" => "1"}, "active"=>"0"}, # Design
754 772 "10"=> {"parent_id"=>"10", "custom_field_values"=>{"7"=>"0"}, "active"=>"1"} # Development, Change custom value
755 773 }
756 774 assert_response :redirect
757 775
758 776 # TimeEntries shouldn't have been reassigned on the failed record
759 777 assert_equal 3, TimeEntry.find_all_by_activity_id_and_project_id(9, 1).size, "Time Entries are not assigned to system activities"
760 778 # TimeEntries shouldn't have been reassigned on the saved record either
761 779 assert_equal 1, TimeEntry.find_all_by_activity_id_and_project_id(10, 1).size, "Time Entries are not assigned to system activities"
762 780 end
763 781
764 782 # A hook that is manually registered later
765 783 class ProjectBasedTemplate < Redmine::Hook::ViewListener
766 784 def view_layouts_base_html_head(context)
767 785 # Adds a project stylesheet
768 786 stylesheet_link_tag(context[:project].identifier) if context[:project]
769 787 end
770 788 end
771 789 # Don't use this hook now
772 790 Redmine::Hook.clear_listeners
773 791
774 792 def test_hook_response
775 793 Redmine::Hook.add_listener(ProjectBasedTemplate)
776 794 get :show, :id => 1
777 795 assert_tag :tag => 'link', :attributes => {:href => '/stylesheets/ecookbook.css'},
778 796 :parent => {:tag => 'head'}
779 797
780 798 Redmine::Hook.clear_listeners
781 799 end
782 800 end
@@ -1,206 +1,206
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 begin
21 21 require 'mocha'
22 22 rescue
23 23 # Won't run some tests
24 24 end
25 25
26 26 class AccountTest < ActionController::IntegrationTest
27 fixtures :users
27 fixtures :users, :roles
28 28
29 29 # Replace this with your real tests.
30 30 def test_login
31 31 get "my/page"
32 32 assert_redirected_to "/login?back_url=http%3A%2F%2Fwww.example.com%2Fmy%2Fpage"
33 33 log_user('jsmith', 'jsmith')
34 34
35 35 get "my/account"
36 36 assert_response :success
37 37 assert_template "my/account"
38 38 end
39 39
40 40 def test_autologin
41 41 user = User.find(1)
42 42 Setting.autologin = "7"
43 43 Token.delete_all
44 44
45 45 # User logs in with 'autologin' checked
46 46 post '/login', :username => user.login, :password => 'admin', :autologin => 1
47 47 assert_redirected_to 'my/page'
48 48 token = Token.find :first
49 49 assert_not_nil token
50 50 assert_equal user, token.user
51 51 assert_equal 'autologin', token.action
52 52 assert_equal user.id, session[:user_id]
53 53 assert_equal token.value, cookies['autologin']
54 54
55 55 # Session is cleared
56 56 reset!
57 57 User.current = nil
58 58 # Clears user's last login timestamp
59 59 user.update_attribute :last_login_on, nil
60 60 assert_nil user.reload.last_login_on
61 61
62 62 # User comes back with his autologin cookie
63 63 cookies[:autologin] = token.value
64 64 get '/my/page'
65 65 assert_response :success
66 66 assert_template 'my/page'
67 67 assert_equal user.id, session[:user_id]
68 68 assert_not_nil user.reload.last_login_on
69 69 assert user.last_login_on.utc > 10.second.ago.utc
70 70 end
71 71
72 72 def test_lost_password
73 73 Token.delete_all
74 74
75 75 get "account/lost_password"
76 76 assert_response :success
77 77 assert_template "account/lost_password"
78 78
79 79 post "account/lost_password", :mail => 'jSmith@somenet.foo'
80 80 assert_redirected_to "/login"
81 81
82 82 token = Token.find(:first)
83 83 assert_equal 'recovery', token.action
84 84 assert_equal 'jsmith@somenet.foo', token.user.mail
85 85 assert !token.expired?
86 86
87 87 get "account/lost_password", :token => token.value
88 88 assert_response :success
89 89 assert_template "account/password_recovery"
90 90
91 91 post "account/lost_password", :token => token.value, :new_password => 'newpass', :new_password_confirmation => 'newpass'
92 92 assert_redirected_to "/login"
93 93 assert_equal 'Password was successfully updated.', flash[:notice]
94 94
95 95 log_user('jsmith', 'newpass')
96 96 assert_equal 0, Token.count
97 97 end
98 98
99 99 def test_register_with_automatic_activation
100 100 Setting.self_registration = '3'
101 101
102 102 get 'account/register'
103 103 assert_response :success
104 104 assert_template 'account/register'
105 105
106 106 post 'account/register', :user => {:login => "newuser", :language => "en", :firstname => "New", :lastname => "User", :mail => "newuser@foo.bar"},
107 107 :password => "newpass", :password_confirmation => "newpass"
108 108 assert_redirected_to 'my/account'
109 109 follow_redirect!
110 110 assert_response :success
111 111 assert_template 'my/account'
112 112
113 113 user = User.find_by_login('newuser')
114 114 assert_not_nil user
115 115 assert user.active?
116 116 assert_not_nil user.last_login_on
117 117 end
118 118
119 119 def test_register_with_manual_activation
120 120 Setting.self_registration = '2'
121 121
122 122 post 'account/register', :user => {:login => "newuser", :language => "en", :firstname => "New", :lastname => "User", :mail => "newuser@foo.bar"},
123 123 :password => "newpass", :password_confirmation => "newpass"
124 124 assert_redirected_to '/login'
125 125 assert !User.find_by_login('newuser').active?
126 126 end
127 127
128 128 def test_register_with_email_activation
129 129 Setting.self_registration = '1'
130 130 Token.delete_all
131 131
132 132 post 'account/register', :user => {:login => "newuser", :language => "en", :firstname => "New", :lastname => "User", :mail => "newuser@foo.bar"},
133 133 :password => "newpass", :password_confirmation => "newpass"
134 134 assert_redirected_to '/login'
135 135 assert !User.find_by_login('newuser').active?
136 136
137 137 token = Token.find(:first)
138 138 assert_equal 'register', token.action
139 139 assert_equal 'newuser@foo.bar', token.user.mail
140 140 assert !token.expired?
141 141
142 142 get 'account/activate', :token => token.value
143 143 assert_redirected_to '/login'
144 144 log_user('newuser', 'newpass')
145 145 end
146 146
147 147 if Object.const_defined?(:Mocha)
148 148
149 149 def test_onthefly_registration
150 150 # disable registration
151 151 Setting.self_registration = '0'
152 152 AuthSource.expects(:authenticate).returns([:login => 'foo', :firstname => 'Foo', :lastname => 'Smith', :mail => 'foo@bar.com', :auth_source_id => 66])
153 153
154 154 post 'account/login', :username => 'foo', :password => 'bar'
155 155 assert_redirected_to 'my/page'
156 156
157 157 user = User.find_by_login('foo')
158 158 assert user.is_a?(User)
159 159 assert_equal 66, user.auth_source_id
160 160 assert user.hashed_password.blank?
161 161 end
162 162
163 163 def test_onthefly_registration_with_invalid_attributes
164 164 # disable registration
165 165 Setting.self_registration = '0'
166 166 AuthSource.expects(:authenticate).returns([:login => 'foo', :lastname => 'Smith', :auth_source_id => 66])
167 167
168 168 post 'account/login', :username => 'foo', :password => 'bar'
169 169 assert_response :success
170 170 assert_template 'account/register'
171 171 assert_tag :input, :attributes => { :name => 'user[firstname]', :value => '' }
172 172 assert_tag :input, :attributes => { :name => 'user[lastname]', :value => 'Smith' }
173 173 assert_no_tag :input, :attributes => { :name => 'user[login]' }
174 174 assert_no_tag :input, :attributes => { :name => 'user[password]' }
175 175
176 176 post 'account/register', :user => {:firstname => 'Foo', :lastname => 'Smith', :mail => 'foo@bar.com'}
177 177 assert_redirected_to '/my/account'
178 178
179 179 user = User.find_by_login('foo')
180 180 assert user.is_a?(User)
181 181 assert_equal 66, user.auth_source_id
182 182 assert user.hashed_password.blank?
183 183 end
184 184
185 185 def test_login_and_logout_should_clear_session
186 186 get '/login'
187 187 sid = session[:session_id]
188 188
189 189 post '/login', :username => 'admin', :password => 'admin'
190 190 assert_redirected_to 'my/page'
191 191 assert_not_equal sid, session[:session_id], "login should reset session"
192 192 assert_equal 1, session[:user_id]
193 193 sid = session[:session_id]
194 194
195 195 get '/'
196 196 assert_equal sid, session[:session_id]
197 197
198 198 get '/logout'
199 199 assert_not_equal sid, session[:session_id], "logout should reset session"
200 200 assert_nil session[:user_id]
201 201 end
202 202
203 203 else
204 204 puts 'Mocha is missing. Skipping tests.'
205 205 end
206 206 end
@@ -1,77 +1,78
1 1 # redMine - project management software
2 2 # Copyright (C) 2006 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 ENV["RAILS_ENV"] ||= "test"
19 19 require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
20 20 require 'test_help'
21 21 require File.expand_path(File.dirname(__FILE__) + '/helper_testcase')
22 22 require File.join(RAILS_ROOT,'test', 'mocks', 'open_id_authentication_mock.rb')
23 23
24 24 require File.expand_path(File.dirname(__FILE__) + '/object_daddy_helpers')
25 25 include ObjectDaddyHelpers
26 26
27 27 class ActiveSupport::TestCase
28 28 # Transactional fixtures accelerate your tests by wrapping each test method
29 29 # in a transaction that's rolled back on completion. This ensures that the
30 30 # test database remains unchanged so your fixtures don't have to be reloaded
31 31 # between every test method. Fewer database queries means faster tests.
32 32 #
33 33 # Read Mike Clark's excellent walkthrough at
34 34 # http://clarkware.com/cgi/blosxom/2005/10/24#Rails10FastTesting
35 35 #
36 36 # Every Active Record database supports transactions except MyISAM tables
37 37 # in MySQL. Turn off transactional fixtures in this case; however, if you
38 38 # don't care one way or the other, switching from MyISAM to InnoDB tables
39 39 # is recommended.
40 40 self.use_transactional_fixtures = true
41 41
42 42 # Instantiated fixtures are slow, but give you @david where otherwise you
43 43 # would need people(:david). If you don't want to migrate your existing
44 44 # test cases which use the @david style and don't mind the speed hit (each
45 45 # instantiated fixtures translates to a database query per test method),
46 46 # then set this back to true.
47 47 self.use_instantiated_fixtures = false
48 48
49 49 # Add more helper methods to be used by all tests here...
50 50
51 51 def log_user(login, password)
52 User.anonymous
52 53 get "/login"
53 54 assert_equal nil, session[:user_id]
54 55 assert_response :success
55 56 assert_template "account/login"
56 57 post "/login", :username => login, :password => password
57 58 assert_equal login, User.find(session[:user_id]).login
58 59 end
59 60
60 61 def uploaded_test_file(name, mime)
61 62 ActionController::TestUploadedFile.new(ActiveSupport::TestCase.fixture_path + "/files/#{name}", mime)
62 63 end
63 64
64 65 # Use a temporary directory for attachment related tests
65 66 def set_tmp_attachments_directory
66 67 Dir.mkdir "#{RAILS_ROOT}/tmp/test" unless File.directory?("#{RAILS_ROOT}/tmp/test")
67 68 Dir.mkdir "#{RAILS_ROOT}/tmp/test/attachments" unless File.directory?("#{RAILS_ROOT}/tmp/test/attachments")
68 69 Attachment.storage_path = "#{RAILS_ROOT}/tmp/test/attachments"
69 70 end
70 71
71 72 def with_settings(options, &block)
72 73 saved_settings = options.keys.inject({}) {|h, k| h[k] = Setting[k].dup; h}
73 74 options.each {|k, v| Setting[k] = v}
74 75 yield
75 76 saved_settings.each {|k, v| Setting[k] = v}
76 77 end
77 78 end
@@ -1,111 +1,111
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 File.dirname(__FILE__) + '/../test_helper'
19 19
20 20 class EnumerationTest < ActiveSupport::TestCase
21 21 fixtures :enumerations, :issues, :custom_fields, :custom_values
22 22
23 23 def setup
24 24 end
25 25
26 26 def test_objects_count
27 27 # low priority
28 assert_equal 5, Enumeration.find(4).objects_count
28 assert_equal 6, Enumeration.find(4).objects_count
29 29 # urgent
30 30 assert_equal 0, Enumeration.find(7).objects_count
31 31 end
32 32
33 33 def test_in_use
34 34 # low priority
35 35 assert Enumeration.find(4).in_use?
36 36 # urgent
37 37 assert !Enumeration.find(7).in_use?
38 38 end
39 39
40 40 def test_default
41 41 e = Enumeration.default
42 42 assert e.is_a?(Enumeration)
43 43 assert e.is_default?
44 44 assert_equal 'Default Enumeration', e.name
45 45 end
46 46
47 47 def test_create
48 48 e = Enumeration.new(:name => 'Not default', :is_default => false)
49 49 e.type = 'Enumeration'
50 50 assert e.save
51 51 assert_equal 'Default Enumeration', Enumeration.default.name
52 52 end
53 53
54 54 def test_create_as_default
55 55 e = Enumeration.new(:name => 'Very urgent', :is_default => true)
56 56 e.type = 'Enumeration'
57 57 assert e.save
58 58 assert_equal e, Enumeration.default
59 59 end
60 60
61 61 def test_update_default
62 62 e = Enumeration.default
63 63 e.update_attributes(:name => 'Changed', :is_default => true)
64 64 assert_equal e, Enumeration.default
65 65 end
66 66
67 67 def test_update_default_to_non_default
68 68 e = Enumeration.default
69 69 e.update_attributes(:name => 'Changed', :is_default => false)
70 70 assert_nil Enumeration.default
71 71 end
72 72
73 73 def test_change_default
74 74 e = Enumeration.find_by_name('Default Enumeration')
75 75 e.update_attributes(:name => 'Changed Enumeration', :is_default => true)
76 76 assert_equal e, Enumeration.default
77 77 end
78 78
79 79 def test_destroy_with_reassign
80 80 Enumeration.find(4).destroy(Enumeration.find(6))
81 81 assert_nil Issue.find(:first, :conditions => {:priority_id => 4})
82 assert_equal 5, Enumeration.find(6).objects_count
82 assert_equal 6, Enumeration.find(6).objects_count
83 83 end
84 84
85 85 def test_should_be_customizable
86 86 assert Enumeration.included_modules.include?(Redmine::Acts::Customizable::InstanceMethods)
87 87 end
88 88
89 89 def test_should_belong_to_a_project
90 90 association = Enumeration.reflect_on_association(:project)
91 91 assert association, "No Project association found"
92 92 assert_equal :belongs_to, association.macro
93 93 end
94 94
95 95 def test_should_act_as_tree
96 96 enumeration = Enumeration.find(4)
97 97
98 98 assert enumeration.respond_to?(:parent)
99 99 assert enumeration.respond_to?(:children)
100 100 end
101 101
102 102 def test_is_override
103 103 # Defaults to off
104 104 enumeration = Enumeration.find(4)
105 105 assert !enumeration.is_override?
106 106
107 107 # Setup as an override
108 108 enumeration.parent = Enumeration.find(5)
109 109 assert enumeration.is_override?
110 110 end
111 111 end
@@ -1,38 +1,38
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 File.dirname(__FILE__) + '/../test_helper'
19 19
20 20 class IssuePriorityTest < ActiveSupport::TestCase
21 21 fixtures :enumerations, :issues
22 22
23 23 def test_should_be_an_enumeration
24 24 assert IssuePriority.ancestors.include?(Enumeration)
25 25 end
26 26
27 27 def test_objects_count
28 28 # low priority
29 assert_equal 5, IssuePriority.find(4).objects_count
29 assert_equal 6, IssuePriority.find(4).objects_count
30 30 # urgent
31 31 assert_equal 0, IssuePriority.find(7).objects_count
32 32 end
33 33
34 34 def test_option_name
35 35 assert_equal :enumeration_issue_priorities, IssuePriority.new.option_name
36 36 end
37 37 end
38 38
@@ -1,475 +1,515
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 :versions,
24 24 :issue_statuses, :issue_categories, :issue_relations, :workflows,
25 25 :enumerations,
26 26 :issues,
27 27 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
28 28 :time_entries
29 29
30 30 def test_create
31 31 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')
32 32 assert issue.save
33 33 issue.reload
34 34 assert_equal 1.5, issue.estimated_hours
35 35 end
36 36
37 37 def test_create_minimal
38 38 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create')
39 39 assert issue.save
40 40 assert issue.description.nil?
41 41 end
42 42
43 43 def test_create_with_required_custom_field
44 44 field = IssueCustomField.find_by_name('Database')
45 45 field.update_attribute(:is_required, true)
46 46
47 47 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')
48 48 assert issue.available_custom_fields.include?(field)
49 49 # No value for the custom field
50 50 assert !issue.save
51 51 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
52 52 # Blank value
53 53 issue.custom_field_values = { field.id => '' }
54 54 assert !issue.save
55 55 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
56 56 # Invalid value
57 57 issue.custom_field_values = { field.id => 'SQLServer' }
58 58 assert !issue.save
59 59 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
60 60 # Valid value
61 61 issue.custom_field_values = { field.id => 'PostgreSQL' }
62 62 assert issue.save
63 63 issue.reload
64 64 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
65 65 end
66 66
67 67 def test_visible_scope_for_anonymous
68 68 # Anonymous user should see issues of public projects only
69 69 issues = Issue.visible(User.anonymous).all
70 70 assert issues.any?
71 71 assert_nil issues.detect {|issue| !issue.project.is_public?}
72 72 # Anonymous user should not see issues without permission
73 73 Role.anonymous.remove_permission!(:view_issues)
74 74 issues = Issue.visible(User.anonymous).all
75 75 assert issues.empty?
76 76 end
77 77
78 78 def test_visible_scope_for_user
79 79 user = User.find(9)
80 80 assert user.projects.empty?
81 81 # Non member user should see issues of public projects only
82 82 issues = Issue.visible(user).all
83 83 assert issues.any?
84 84 assert_nil issues.detect {|issue| !issue.project.is_public?}
85 85 # Non member user should not see issues without permission
86 86 Role.non_member.remove_permission!(:view_issues)
87 87 user.reload
88 88 issues = Issue.visible(user).all
89 89 assert issues.empty?
90 90 # User should see issues of projects for which he has view_issues permissions only
91 91 Member.create!(:principal => user, :project_id => 2, :role_ids => [1])
92 92 user.reload
93 93 issues = Issue.visible(user).all
94 94 assert issues.any?
95 95 assert_nil issues.detect {|issue| issue.project_id != 2}
96 96 end
97 97
98 98 def test_visible_scope_for_admin
99 99 user = User.find(1)
100 100 user.members.each(&:destroy)
101 101 assert user.projects.empty?
102 102 issues = Issue.visible(user).all
103 103 assert issues.any?
104 104 # Admin should see issues on private projects that he does not belong to
105 105 assert issues.detect {|issue| !issue.project.is_public?}
106 106 end
107 107
108 108 def test_errors_full_messages_should_include_custom_fields_errors
109 109 field = IssueCustomField.find_by_name('Database')
110 110
111 111 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')
112 112 assert issue.available_custom_fields.include?(field)
113 113 # Invalid value
114 114 issue.custom_field_values = { field.id => 'SQLServer' }
115 115
116 116 assert !issue.valid?
117 117 assert_equal 1, issue.errors.full_messages.size
118 118 assert_equal "Database #{I18n.translate('activerecord.errors.messages.inclusion')}", issue.errors.full_messages.first
119 119 end
120 120
121 121 def test_update_issue_with_required_custom_field
122 122 field = IssueCustomField.find_by_name('Database')
123 123 field.update_attribute(:is_required, true)
124 124
125 125 issue = Issue.find(1)
126 126 assert_nil issue.custom_value_for(field)
127 127 assert issue.available_custom_fields.include?(field)
128 128 # No change to custom values, issue can be saved
129 129 assert issue.save
130 130 # Blank value
131 131 issue.custom_field_values = { field.id => '' }
132 132 assert !issue.save
133 133 # Valid value
134 134 issue.custom_field_values = { field.id => 'PostgreSQL' }
135 135 assert issue.save
136 136 issue.reload
137 137 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
138 138 end
139 139
140 140 def test_should_not_update_attributes_if_custom_fields_validation_fails
141 141 issue = Issue.find(1)
142 142 field = IssueCustomField.find_by_name('Database')
143 143 assert issue.available_custom_fields.include?(field)
144 144
145 145 issue.custom_field_values = { field.id => 'Invalid' }
146 146 issue.subject = 'Should be not be saved'
147 147 assert !issue.save
148 148
149 149 issue.reload
150 150 assert_equal "Can't print recipes", issue.subject
151 151 end
152 152
153 153 def test_should_not_recreate_custom_values_objects_on_update
154 154 field = IssueCustomField.find_by_name('Database')
155 155
156 156 issue = Issue.find(1)
157 157 issue.custom_field_values = { field.id => 'PostgreSQL' }
158 158 assert issue.save
159 159 custom_value = issue.custom_value_for(field)
160 160 issue.reload
161 161 issue.custom_field_values = { field.id => 'MySQL' }
162 162 assert issue.save
163 163 issue.reload
164 164 assert_equal custom_value.id, issue.custom_value_for(field).id
165 165 end
166 166
167 167 def test_should_update_issue_with_disabled_tracker
168 168 p = Project.find(1)
169 169 issue = Issue.find(1)
170 170
171 171 p.trackers.delete(issue.tracker)
172 172 assert !p.trackers.include?(issue.tracker)
173 173
174 174 issue.reload
175 175 issue.subject = 'New subject'
176 176 assert issue.save
177 177 end
178 178
179 179 def test_should_not_set_a_disabled_tracker
180 180 p = Project.find(1)
181 181 p.trackers.delete(Tracker.find(2))
182 182
183 183 issue = Issue.find(1)
184 184 issue.tracker_id = 2
185 185 issue.subject = 'New subject'
186 186 assert !issue.save
187 187 assert_not_nil issue.errors.on(:tracker_id)
188 188 end
189 189
190 190 def test_category_based_assignment
191 191 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)
192 192 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
193 193 end
194 194
195 195 def test_copy
196 196 issue = Issue.new.copy_from(1)
197 197 assert issue.save
198 198 issue.reload
199 199 orig = Issue.find(1)
200 200 assert_equal orig.subject, issue.subject
201 201 assert_equal orig.tracker, issue.tracker
202 202 assert_equal orig.custom_values.first.value, issue.custom_values.first.value
203 203 end
204 204
205 205 def test_copy_should_copy_status
206 206 orig = Issue.find(8)
207 207 assert orig.status != IssueStatus.default
208 208
209 209 issue = Issue.new.copy_from(orig)
210 210 assert issue.save
211 211 issue.reload
212 212 assert_equal orig.status, issue.status
213 213 end
214 214
215 215 def test_should_close_duplicates
216 216 # Create 3 issues
217 217 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')
218 218 assert issue1.save
219 219 issue2 = issue1.clone
220 220 assert issue2.save
221 221 issue3 = issue1.clone
222 222 assert issue3.save
223 223
224 224 # 2 is a dupe of 1
225 225 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
226 226 # And 3 is a dupe of 2
227 227 IssueRelation.create(:issue_from => issue3, :issue_to => issue2, :relation_type => IssueRelation::TYPE_DUPLICATES)
228 228 # And 3 is a dupe of 1 (circular duplicates)
229 229 IssueRelation.create(:issue_from => issue3, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
230 230
231 231 assert issue1.reload.duplicates.include?(issue2)
232 232
233 233 # Closing issue 1
234 234 issue1.init_journal(User.find(:first), "Closing issue1")
235 235 issue1.status = IssueStatus.find :first, :conditions => {:is_closed => true}
236 236 assert issue1.save
237 237 # 2 and 3 should be also closed
238 238 assert issue2.reload.closed?
239 239 assert issue3.reload.closed?
240 240 end
241 241
242 242 def test_should_not_close_duplicated_issue
243 243 # Create 3 issues
244 244 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')
245 245 assert issue1.save
246 246 issue2 = issue1.clone
247 247 assert issue2.save
248 248
249 249 # 2 is a dupe of 1
250 250 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
251 251 # 2 is a dup of 1 but 1 is not a duplicate of 2
252 252 assert !issue2.reload.duplicates.include?(issue1)
253 253
254 254 # Closing issue 2
255 255 issue2.init_journal(User.find(:first), "Closing issue2")
256 256 issue2.status = IssueStatus.find :first, :conditions => {:is_closed => true}
257 257 assert issue2.save
258 258 # 1 should not be also closed
259 259 assert !issue1.reload.closed?
260 260 end
261 261
262 262 def test_assignable_versions
263 263 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
264 264 assert_equal ['open'], issue.assignable_versions.collect(&:status).uniq
265 265 end
266 266
267 267 def test_should_not_be_able_to_assign_a_new_issue_to_a_closed_version
268 268 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
269 269 assert !issue.save
270 270 assert_not_nil issue.errors.on(:fixed_version_id)
271 271 end
272 272
273 273 def test_should_not_be_able_to_assign_a_new_issue_to_a_locked_version
274 274 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 2, :subject => 'New issue')
275 275 assert !issue.save
276 276 assert_not_nil issue.errors.on(:fixed_version_id)
277 277 end
278 278
279 279 def test_should_be_able_to_assign_a_new_issue_to_an_open_version
280 280 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 3, :subject => 'New issue')
281 281 assert issue.save
282 282 end
283 283
284 284 def test_should_be_able_to_update_an_issue_assigned_to_a_closed_version
285 285 issue = Issue.find(11)
286 286 assert_equal 'closed', issue.fixed_version.status
287 287 issue.subject = 'Subject changed'
288 288 assert issue.save
289 289 end
290 290
291 291 def test_should_not_be_able_to_reopen_an_issue_assigned_to_a_closed_version
292 292 issue = Issue.find(11)
293 293 issue.status_id = 1
294 294 assert !issue.save
295 295 assert_not_nil issue.errors.on_base
296 296 end
297 297
298 298 def test_should_be_able_to_reopen_and_reassign_an_issue_assigned_to_a_closed_version
299 299 issue = Issue.find(11)
300 300 issue.status_id = 1
301 301 issue.fixed_version_id = 3
302 302 assert issue.save
303 303 end
304 304
305 305 def test_should_be_able_to_reopen_an_issue_assigned_to_a_locked_version
306 306 issue = Issue.find(12)
307 307 assert_equal 'locked', issue.fixed_version.status
308 308 issue.status_id = 1
309 309 assert issue.save
310 310 end
311 311
312 312 def test_move_to_another_project_with_same_category
313 313 issue = Issue.find(1)
314 314 assert issue.move_to(Project.find(2))
315 315 issue.reload
316 316 assert_equal 2, issue.project_id
317 317 # Category changes
318 318 assert_equal 4, issue.category_id
319 319 # Make sure time entries were move to the target project
320 320 assert_equal 2, issue.time_entries.first.project_id
321 321 end
322 322
323 323 def test_move_to_another_project_without_same_category
324 324 issue = Issue.find(2)
325 325 assert issue.move_to(Project.find(2))
326 326 issue.reload
327 327 assert_equal 2, issue.project_id
328 328 # Category cleared
329 329 assert_nil issue.category_id
330 330 end
331 331
332 def test_move_to_another_project_should_clear_fixed_version_when_not_shared
333 issue = Issue.find(1)
334 issue.update_attribute(:fixed_version_id, 1)
335 assert issue.move_to(Project.find(2))
336 issue.reload
337 assert_equal 2, issue.project_id
338 # Cleared fixed_version
339 assert_equal nil, issue.fixed_version
340 end
341
342 def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
343 issue = Issue.find(1)
344 issue.update_attribute(:fixed_version_id, 4)
345 assert issue.move_to(Project.find(5))
346 issue.reload
347 assert_equal 5, issue.project_id
348 # Keep fixed_version
349 assert_equal 4, issue.fixed_version_id
350 end
351
352 def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
353 issue = Issue.find(1)
354 issue.update_attribute(:fixed_version_id, 1)
355 assert issue.move_to(Project.find(5))
356 issue.reload
357 assert_equal 5, issue.project_id
358 # Cleared fixed_version
359 assert_equal nil, issue.fixed_version
360 end
361
362 def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
363 issue = Issue.find(1)
364 issue.update_attribute(:fixed_version_id, 7)
365 assert issue.move_to(Project.find(2))
366 issue.reload
367 assert_equal 2, issue.project_id
368 # Keep fixed_version
369 assert_equal 7, issue.fixed_version_id
370 end
371
332 372 def test_copy_to_the_same_project
333 373 issue = Issue.find(1)
334 374 copy = nil
335 375 assert_difference 'Issue.count' do
336 376 copy = issue.move_to(issue.project, nil, :copy => true)
337 377 end
338 378 assert_kind_of Issue, copy
339 379 assert_equal issue.project, copy.project
340 380 assert_equal "125", copy.custom_value_for(2).value
341 381 end
342 382
343 383 def test_copy_to_another_project_and_tracker
344 384 issue = Issue.find(1)
345 385 copy = nil
346 386 assert_difference 'Issue.count' do
347 387 copy = issue.move_to(Project.find(3), Tracker.find(2), :copy => true)
348 388 end
349 389 assert_kind_of Issue, copy
350 390 assert_equal Project.find(3), copy.project
351 391 assert_equal Tracker.find(2), copy.tracker
352 392 # Custom field #2 is not associated with target tracker
353 393 assert_nil copy.custom_value_for(2)
354 394 end
355 395
356 396 context "#move_to" do
357 397 context "as a copy" do
358 398 setup do
359 399 @issue = Issue.find(1)
360 400 @copy = nil
361 401 end
362 402
363 403 should "allow assigned_to changes" do
364 404 @copy = @issue.move_to(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:assigned_to_id => 3}})
365 405 assert_equal 3, @copy.assigned_to_id
366 406 end
367 407
368 408 should "allow status changes" do
369 409 @copy = @issue.move_to(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:status_id => 2}})
370 410 assert_equal 2, @copy.status_id
371 411 end
372 412
373 413 should "allow start date changes" do
374 414 date = Date.today
375 415 @copy = @issue.move_to(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:start_date => date}})
376 416 assert_equal date, @copy.start_date
377 417 end
378 418
379 419 should "allow due date changes" do
380 420 date = Date.today
381 421 @copy = @issue.move_to(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:due_date => date}})
382 422
383 423 assert_equal date, @copy.due_date
384 424 end
385 425 end
386 426 end
387 427
388 428 def test_recipients_should_not_include_users_that_cannot_view_the_issue
389 429 issue = Issue.find(12)
390 430 assert issue.recipients.include?(issue.author.mail)
391 431 # move the issue to a private project
392 432 copy = issue.move_to(Project.find(5), Tracker.find(2), :copy => true)
393 433 # author is not a member of project anymore
394 434 assert !copy.recipients.include?(copy.author.mail)
395 435 end
396 436
397 437 def test_watcher_recipients_should_not_include_users_that_cannot_view_the_issue
398 438 user = User.find(3)
399 439 issue = Issue.find(9)
400 440 Watcher.create!(:user => user, :watchable => issue)
401 441 assert issue.watched_by?(user)
402 442 assert !issue.watcher_recipients.include?(user.mail)
403 443 end
404 444
405 445 def test_issue_destroy
406 446 Issue.find(1).destroy
407 447 assert_nil Issue.find_by_id(1)
408 448 assert_nil TimeEntry.find_by_issue_id(1)
409 449 end
410 450
411 451 def test_blocked
412 452 blocked_issue = Issue.find(9)
413 453 blocking_issue = Issue.find(10)
414 454
415 455 assert blocked_issue.blocked?
416 456 assert !blocking_issue.blocked?
417 457 end
418 458
419 459 def test_blocked_issues_dont_allow_closed_statuses
420 460 blocked_issue = Issue.find(9)
421 461
422 462 allowed_statuses = blocked_issue.new_statuses_allowed_to(users(:users_002))
423 463 assert !allowed_statuses.empty?
424 464 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
425 465 assert closed_statuses.empty?
426 466 end
427 467
428 468 def test_unblocked_issues_allow_closed_statuses
429 469 blocking_issue = Issue.find(10)
430 470
431 471 allowed_statuses = blocking_issue.new_statuses_allowed_to(users(:users_002))
432 472 assert !allowed_statuses.empty?
433 473 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
434 474 assert !closed_statuses.empty?
435 475 end
436 476
437 477 def test_overdue
438 478 assert Issue.new(:due_date => 1.day.ago.to_date).overdue?
439 479 assert !Issue.new(:due_date => Date.today).overdue?
440 480 assert !Issue.new(:due_date => 1.day.from_now.to_date).overdue?
441 481 assert !Issue.new(:due_date => nil).overdue?
442 482 assert !Issue.new(:due_date => 1.day.ago.to_date, :status => IssueStatus.find(:first, :conditions => {:is_closed => true})).overdue?
443 483 end
444 484
445 485 def test_assignable_users
446 486 assert_kind_of User, Issue.find(1).assignable_users.first
447 487 end
448 488
449 489 def test_create_should_send_email_notification
450 490 ActionMailer::Base.deliveries.clear
451 491 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')
452 492
453 493 assert issue.save
454 494 assert_equal 1, ActionMailer::Base.deliveries.size
455 495 end
456 496
457 497 def test_stale_issue_should_not_send_email_notification
458 498 ActionMailer::Base.deliveries.clear
459 499 issue = Issue.find(1)
460 500 stale = Issue.find(1)
461 501
462 502 issue.init_journal(User.find(1))
463 503 issue.subject = 'Subjet update'
464 504 assert issue.save
465 505 assert_equal 1, ActionMailer::Base.deliveries.size
466 506 ActionMailer::Base.deliveries.clear
467 507
468 508 stale.init_journal(User.find(1))
469 509 stale.subject = 'Another subjet update'
470 510 assert_raise ActiveRecord::StaleObjectError do
471 511 stale.save
472 512 end
473 513 assert ActionMailer::Base.deliveries.empty?
474 514 end
475 515 end
@@ -1,560 +1,650
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 ProjectTest < ActiveSupport::TestCase
21 fixtures :projects, :enabled_modules,
22 :issues, :issue_statuses, :journals, :journal_details,
23 :users, :members, :member_roles, :roles, :projects_trackers, :trackers, :boards,
24 :queries
21 fixtures :all
25 22
26 23 def setup
27 24 @ecookbook = Project.find(1)
28 25 @ecookbook_sub1 = Project.find(3)
29 26 User.current = nil
30 27 end
31 28
32 29 should_validate_presence_of :name
33 30 should_validate_presence_of :identifier
34 31
35 32 should_validate_uniqueness_of :name
36 33 should_validate_uniqueness_of :identifier
37 34
38 35 context "associations" do
39 36 should_have_many :members
40 37 should_have_many :users, :through => :members
41 38 should_have_many :member_principals
42 39 should_have_many :principals, :through => :member_principals
43 40 should_have_many :enabled_modules
44 41 should_have_many :issues
45 42 should_have_many :issue_changes, :through => :issues
46 43 should_have_many :versions
47 44 should_have_many :time_entries
48 45 should_have_many :queries
49 46 should_have_many :documents
50 47 should_have_many :news
51 48 should_have_many :issue_categories
52 49 should_have_many :boards
53 50 should_have_many :changesets, :through => :repository
54 51
55 52 should_have_one :repository
56 53 should_have_one :wiki
57 54
58 55 should_have_and_belong_to_many :trackers
59 56 should_have_and_belong_to_many :issue_custom_fields
60 57 end
61 58
62 59 def test_truth
63 60 assert_kind_of Project, @ecookbook
64 61 assert_equal "eCookbook", @ecookbook.name
65 62 end
66 63
67 64 def test_update
68 65 assert_equal "eCookbook", @ecookbook.name
69 66 @ecookbook.name = "eCook"
70 67 assert @ecookbook.save, @ecookbook.errors.full_messages.join("; ")
71 68 @ecookbook.reload
72 69 assert_equal "eCook", @ecookbook.name
73 70 end
74 71
75 72 def test_validate_identifier
76 73 to_test = {"abc" => true,
77 74 "ab12" => true,
78 75 "ab-12" => true,
79 76 "12" => false,
80 77 "new" => false}
81 78
82 79 to_test.each do |identifier, valid|
83 80 p = Project.new
84 81 p.identifier = identifier
85 82 p.valid?
86 83 assert_equal valid, p.errors.on('identifier').nil?
87 84 end
88 85 end
89 86
90 87 def test_members_should_be_active_users
91 88 Project.all.each do |project|
92 89 assert_nil project.members.detect {|m| !(m.user.is_a?(User) && m.user.active?) }
93 90 end
94 91 end
95 92
96 93 def test_users_should_be_active_users
97 94 Project.all.each do |project|
98 95 assert_nil project.users.detect {|u| !(u.is_a?(User) && u.active?) }
99 96 end
100 97 end
101 98
102 99 def test_archive
103 100 user = @ecookbook.members.first.user
104 101 @ecookbook.archive
105 102 @ecookbook.reload
106 103
107 104 assert !@ecookbook.active?
108 105 assert !user.projects.include?(@ecookbook)
109 106 # Subproject are also archived
110 107 assert !@ecookbook.children.empty?
111 108 assert @ecookbook.descendants.active.empty?
112 109 end
113 110
111 def test_archive_should_fail_if_versions_are_used_by_non_descendant_projects
112 # Assign an issue of a project to a version of a child project
113 Issue.find(4).update_attribute :fixed_version_id, 4
114
115 assert_no_difference "Project.count(:all, :conditions => 'status = #{Project::STATUS_ARCHIVED}')" do
116 assert_equal false, @ecookbook.archive
117 end
118 @ecookbook.reload
119 assert @ecookbook.active?
120 end
121
114 122 def test_unarchive
115 123 user = @ecookbook.members.first.user
116 124 @ecookbook.archive
117 125 # A subproject of an archived project can not be unarchived
118 126 assert !@ecookbook_sub1.unarchive
119 127
120 128 # Unarchive project
121 129 assert @ecookbook.unarchive
122 130 @ecookbook.reload
123 131 assert @ecookbook.active?
124 132 assert user.projects.include?(@ecookbook)
125 133 # Subproject can now be unarchived
126 134 @ecookbook_sub1.reload
127 135 assert @ecookbook_sub1.unarchive
128 136 end
129 137
130 138 def test_destroy
131 139 # 2 active members
132 140 assert_equal 2, @ecookbook.members.size
133 141 # and 1 is locked
134 142 assert_equal 3, Member.find(:all, :conditions => ['project_id = ?', @ecookbook.id]).size
135 143 # some boards
136 144 assert @ecookbook.boards.any?
137 145
138 146 @ecookbook.destroy
139 147 # make sure that the project non longer exists
140 148 assert_raise(ActiveRecord::RecordNotFound) { Project.find(@ecookbook.id) }
141 149 # make sure related data was removed
142 150 assert Member.find(:all, :conditions => ['project_id = ?', @ecookbook.id]).empty?
143 151 assert Board.find(:all, :conditions => ['project_id = ?', @ecookbook.id]).empty?
144 152 end
145 153
146 154 def test_move_an_orphan_project_to_a_root_project
147 155 sub = Project.find(2)
148 156 sub.set_parent! @ecookbook
149 157 assert_equal @ecookbook.id, sub.parent.id
150 158 @ecookbook.reload
151 159 assert_equal 4, @ecookbook.children.size
152 160 end
153 161
154 162 def test_move_an_orphan_project_to_a_subproject
155 163 sub = Project.find(2)
156 164 assert sub.set_parent!(@ecookbook_sub1)
157 165 end
158 166
159 167 def test_move_a_root_project_to_a_project
160 168 sub = @ecookbook
161 169 assert sub.set_parent!(Project.find(2))
162 170 end
163 171
164 172 def test_should_not_move_a_project_to_its_children
165 173 sub = @ecookbook
166 174 assert !(sub.set_parent!(Project.find(3)))
167 175 end
168 176
169 177 def test_set_parent_should_add_roots_in_alphabetical_order
170 178 ProjectCustomField.delete_all
171 179 Project.delete_all
172 180 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(nil)
173 181 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(nil)
174 182 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(nil)
175 183 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(nil)
176 184
177 185 assert_equal 4, Project.count
178 186 assert_equal Project.all.sort_by(&:name), Project.all.sort_by(&:lft)
179 187 end
180 188
181 189 def test_set_parent_should_add_children_in_alphabetical_order
182 190 ProjectCustomField.delete_all
183 191 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
184 192 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(parent)
185 193 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(parent)
186 194 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(parent)
187 195 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(parent)
188 196
189 197 parent.reload
190 198 assert_equal 4, parent.children.size
191 199 assert_equal parent.children.sort_by(&:name), parent.children
192 200 end
193 201
194 202 def test_rebuild_should_sort_children_alphabetically
195 203 ProjectCustomField.delete_all
196 204 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
197 205 Project.create!(:name => 'Project C', :identifier => 'project-c').move_to_child_of(parent)
198 206 Project.create!(:name => 'Project B', :identifier => 'project-b').move_to_child_of(parent)
199 207 Project.create!(:name => 'Project D', :identifier => 'project-d').move_to_child_of(parent)
200 208 Project.create!(:name => 'Project A', :identifier => 'project-a').move_to_child_of(parent)
201 209
202 210 Project.update_all("lft = NULL, rgt = NULL")
203 211 Project.rebuild!
204 212
205 213 parent.reload
206 214 assert_equal 4, parent.children.size
207 215 assert_equal parent.children.sort_by(&:name), parent.children
208 216 end
217
218
219 def test_set_parent_should_update_issue_fixed_version_associations_when_a_fixed_version_is_moved_out_of_the_hierarchy
220 # Parent issue with a hierarchy project's fixed version
221 parent_issue = Issue.find(1)
222 parent_issue.update_attribute(:fixed_version_id, 4)
223 parent_issue.reload
224 assert_equal 4, parent_issue.fixed_version_id
225
226 # Should keep fixed versions for the issues
227 issue_with_local_fixed_version = Issue.find(5)
228 issue_with_local_fixed_version.update_attribute(:fixed_version_id, 4)
229 issue_with_local_fixed_version.reload
230 assert_equal 4, issue_with_local_fixed_version.fixed_version_id
231
232 # Local issue with hierarchy fixed_version
233 issue_with_hierarchy_fixed_version = Issue.find(13)
234 issue_with_hierarchy_fixed_version.update_attribute(:fixed_version_id, 6)
235 issue_with_hierarchy_fixed_version.reload
236 assert_equal 6, issue_with_hierarchy_fixed_version.fixed_version_id
237
238 # Move project out of the issue's hierarchy
239 moved_project = Project.find(3)
240 moved_project.set_parent!(Project.find(2))
241 parent_issue.reload
242 issue_with_local_fixed_version.reload
243 issue_with_hierarchy_fixed_version.reload
244
245 assert_equal 4, issue_with_local_fixed_version.fixed_version_id, "Fixed version was not keep on an issue local to the moved project"
246 assert_equal nil, issue_with_hierarchy_fixed_version.fixed_version_id, "Fixed version is still set after moving the Project out of the hierarchy where the version is defined in"
247 assert_equal nil, parent_issue.fixed_version_id, "Fixed version is still set after moving the Version out of the hierarchy for the issue."
248 end
209 249
210 250 def test_parent
211 251 p = Project.find(6).parent
212 252 assert p.is_a?(Project)
213 253 assert_equal 5, p.id
214 254 end
215 255
216 256 def test_ancestors
217 257 a = Project.find(6).ancestors
218 258 assert a.first.is_a?(Project)
219 259 assert_equal [1, 5], a.collect(&:id)
220 260 end
221 261
222 262 def test_root
223 263 r = Project.find(6).root
224 264 assert r.is_a?(Project)
225 265 assert_equal 1, r.id
226 266 end
227 267
228 268 def test_children
229 269 c = Project.find(1).children
230 270 assert c.first.is_a?(Project)
231 271 assert_equal [5, 3, 4], c.collect(&:id)
232 272 end
233 273
234 274 def test_descendants
235 275 d = Project.find(1).descendants
236 276 assert d.first.is_a?(Project)
237 277 assert_equal [5, 6, 3, 4], d.collect(&:id)
238 278 end
239 279
240 280 def test_allowed_parents_should_be_empty_for_non_member_user
241 281 Role.non_member.add_permission!(:add_project)
242 282 user = User.find(9)
243 283 assert user.memberships.empty?
244 284 User.current = user
245 285 assert Project.new.allowed_parents.empty?
246 286 end
247 287
248 288 def test_users_by_role
249 289 users_by_role = Project.find(1).users_by_role
250 290 assert_kind_of Hash, users_by_role
251 291 role = Role.find(1)
252 292 assert_kind_of Array, users_by_role[role]
253 293 assert users_by_role[role].include?(User.find(2))
254 294 end
255 295
256 296 def test_rolled_up_trackers
257 297 parent = Project.find(1)
258 298 parent.trackers = Tracker.find([1,2])
259 299 child = parent.children.find(3)
260 300
261 301 assert_equal [1, 2], parent.tracker_ids
262 302 assert_equal [2, 3], child.trackers.collect(&:id)
263 303
264 304 assert_kind_of Tracker, parent.rolled_up_trackers.first
265 305 assert_equal Tracker.find(1), parent.rolled_up_trackers.first
266 306
267 307 assert_equal [1, 2, 3], parent.rolled_up_trackers.collect(&:id)
268 308 assert_equal [2, 3], child.rolled_up_trackers.collect(&:id)
269 309 end
270 310
271 311 def test_rolled_up_trackers_should_ignore_archived_subprojects
272 312 parent = Project.find(1)
273 313 parent.trackers = Tracker.find([1,2])
274 314 child = parent.children.find(3)
275 315 child.trackers = Tracker.find([1,3])
276 316 parent.children.each(&:archive)
277 317
278 318 assert_equal [1,2], parent.rolled_up_trackers.collect(&:id)
279 319 end
320
321 def test_shared_versions
322 parent = Project.find(1)
323 child = parent.children.find(3)
324 private_child = parent.children.find(5)
325
326 assert_equal [1,2,3], parent.version_ids.sort
327 assert_equal [4], child.version_ids
328 assert_equal [6], private_child.version_ids
329 assert_equal [7], Version.find_all_by_sharing('system').collect(&:id)
330
331 assert_equal 6, parent.shared_versions.size
332 parent.shared_versions.each do |version|
333 assert_kind_of Version, version
334 end
335
336 assert_equal [1,2,3,4,6,7], parent.shared_versions.collect(&:id).sort
337 end
338
339 def test_shared_versions_should_ignore_archived_subprojects
340 parent = Project.find(1)
341 child = parent.children.find(3)
342 child.archive
343 parent.reload
344
345 assert_equal [1,2,3], parent.version_ids.sort
346 assert_equal [4], child.version_ids
347 assert !parent.shared_versions.collect(&:id).include?(4)
348 end
349
350 def test_shared_versions_visible_to_user
351 user = User.find(3)
352 parent = Project.find(1)
353 child = parent.children.find(5)
354
355 assert_equal [1,2,3], parent.version_ids.sort
356 assert_equal [6], child.version_ids
357
358 versions = parent.shared_versions.visible(user)
359
360 assert_equal 4, versions.size
361 versions.each do |version|
362 assert_kind_of Version, version
363 end
364
365 assert !versions.collect(&:id).include?(6)
366 end
367
280 368
281 369 def test_next_identifier
282 370 ProjectCustomField.delete_all
283 371 Project.create!(:name => 'last', :identifier => 'p2008040')
284 372 assert_equal 'p2008041', Project.next_identifier
285 373 end
286
374
287 375 def test_next_identifier_first_project
288 376 Project.delete_all
289 377 assert_nil Project.next_identifier
290 378 end
291 379
292 380
293 381 def test_enabled_module_names_should_not_recreate_enabled_modules
294 382 project = Project.find(1)
295 383 # Remove one module
296 384 modules = project.enabled_modules.slice(0..-2)
297 385 assert modules.any?
298 386 assert_difference 'EnabledModule.count', -1 do
299 387 project.enabled_module_names = modules.collect(&:name)
300 388 end
301 389 project.reload
302 390 # Ids should be preserved
303 391 assert_equal project.enabled_module_ids.sort, modules.collect(&:id).sort
304 392 end
305 393
306 394 def test_copy_from_existing_project
307 395 source_project = Project.find(1)
308 396 copied_project = Project.copy_from(1)
309 397
310 398 assert copied_project
311 399 # Cleared attributes
312 400 assert copied_project.id.blank?
313 401 assert copied_project.name.blank?
314 402 assert copied_project.identifier.blank?
315 403
316 404 # Duplicated attributes
317 405 assert_equal source_project.description, copied_project.description
318 406 assert_equal source_project.enabled_modules, copied_project.enabled_modules
319 407 assert_equal source_project.trackers, copied_project.trackers
320 408
321 409 # Default attributes
322 410 assert_equal 1, copied_project.status
323 411 end
324 412
325 413 def test_activities_should_use_the_system_activities
326 414 project = Project.find(1)
327 415 assert_equal project.activities, TimeEntryActivity.find(:all, :conditions => {:active => true} )
328 416 end
329 417
330 418
331 419 def test_activities_should_use_the_project_specific_activities
332 420 project = Project.find(1)
333 421 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project})
334 422 assert overridden_activity.save!
335 423
336 424 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
337 425 end
338 426
339 427 def test_activities_should_not_include_the_inactive_project_specific_activities
340 428 project = Project.find(1)
341 429 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.find(:first), :active => false})
342 430 assert overridden_activity.save!
343 431
344 432 assert !project.activities.include?(overridden_activity), "Inactive Project specific Activity found"
345 433 end
346 434
347 435 def test_activities_should_not_include_project_specific_activities_from_other_projects
348 436 project = Project.find(1)
349 437 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(2)})
350 438 assert overridden_activity.save!
351 439
352 440 assert !project.activities.include?(overridden_activity), "Project specific Activity found on a different project"
353 441 end
354 442
355 443 def test_activities_should_handle_nils
356 444 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => Project.find(1), :parent => TimeEntryActivity.find(:first)})
357 445 TimeEntryActivity.delete_all
358 446
359 447 # No activities
360 448 project = Project.find(1)
361 449 assert project.activities.empty?
362 450
363 451 # No system, one overridden
364 452 assert overridden_activity.save!
365 453 project.reload
366 454 assert_equal [overridden_activity], project.activities
367 455 end
368 456
369 457 def test_activities_should_override_system_activities_with_project_activities
370 458 project = Project.find(1)
371 459 parent_activity = TimeEntryActivity.find(:first)
372 460 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => parent_activity})
373 461 assert overridden_activity.save!
374 462
375 463 assert project.activities.include?(overridden_activity), "Project specific Activity not found"
376 464 assert !project.activities.include?(parent_activity), "System Activity found when it should have been overridden"
377 465 end
378 466
379 467 def test_activities_should_include_inactive_activities_if_specified
380 468 project = Project.find(1)
381 469 overridden_activity = TimeEntryActivity.new({:name => "Project", :project => project, :parent => TimeEntryActivity.find(:first), :active => false})
382 470 assert overridden_activity.save!
383 471
384 472 assert project.activities(true).include?(overridden_activity), "Inactive Project specific Activity not found"
385 473 end
386 474
387 475 def test_close_completed_versions
388 476 Version.update_all("status = 'open'")
389 477 project = Project.find(1)
390 478 assert_not_nil project.versions.detect {|v| v.completed? && v.status == 'open'}
391 479 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
392 480 project.close_completed_versions
393 481 project.reload
394 482 assert_nil project.versions.detect {|v| v.completed? && v.status != 'closed'}
395 483 assert_not_nil project.versions.detect {|v| !v.completed? && v.status == 'open'}
396 484 end
397 485
398 486 context "Project#copy" do
399 487 setup do
400 488 ProjectCustomField.destroy_all # Custom values are a mess to isolate in tests
401 489 Project.destroy_all :identifier => "copy-test"
402 490 @source_project = Project.find(2)
403 491 @project = Project.new(:name => 'Copy Test', :identifier => 'copy-test')
404 492 @project.trackers = @source_project.trackers
405 493 @project.enabled_module_names = @source_project.enabled_modules.collect(&:name)
406 494 end
407 495
408 496 should "copy issues" do
409 497 @source_project.issues << Issue.generate!(:status_id => 5,
410 498 :subject => "copy issue status",
411 499 :tracker_id => 1,
412 500 :assigned_to_id => 2,
413 501 :project_id => @source_project.id)
414 502 assert @project.valid?
415 503 assert @project.issues.empty?
416 504 assert @project.copy(@source_project)
417 505
418 506 assert_equal @source_project.issues.size, @project.issues.size
419 507 @project.issues.each do |issue|
420 508 assert issue.valid?
421 509 assert ! issue.assigned_to.blank?
422 510 assert_equal @project, issue.project
423 511 end
424 512
425 513 copied_issue = @project.issues.first(:conditions => {:subject => "copy issue status"})
426 514 assert copied_issue
427 515 assert copied_issue.status
428 516 assert_equal "Closed", copied_issue.status.name
429 517 end
430 518
431 519 should "change the new issues to use the copied version" do
432 assigned_version = Version.generate!(:name => "Assigned Issues")
520 User.current = User.find(1)
521 assigned_version = Version.generate!(:name => "Assigned Issues", :status => 'open')
433 522 @source_project.versions << assigned_version
434 assert_equal 1, @source_project.versions.size
435 @source_project.issues << Issue.generate!(:fixed_version_id => assigned_version.id,
436 :subject => "change the new issues to use the copied version",
437 :tracker_id => 1,
438 :project_id => @source_project.id)
523 assert_equal 3, @source_project.versions.size
524 Issue.generate_for_project!(@source_project,
525 :fixed_version_id => assigned_version.id,
526 :subject => "change the new issues to use the copied version",
527 :tracker_id => 1,
528 :project_id => @source_project.id)
439 529
440 530 assert @project.copy(@source_project)
441 531 @project.reload
442 532 copied_issue = @project.issues.first(:conditions => {:subject => "change the new issues to use the copied version"})
443 533
444 534 assert copied_issue
445 535 assert copied_issue.fixed_version
446 536 assert_equal "Assigned Issues", copied_issue.fixed_version.name # Same name
447 537 assert_not_equal assigned_version.id, copied_issue.fixed_version.id # Different record
448 538 end
449 539
450 540 should "copy members" do
451 541 assert @project.valid?
452 542 assert @project.members.empty?
453 543 assert @project.copy(@source_project)
454 544
455 545 assert_equal @source_project.members.size, @project.members.size
456 546 @project.members.each do |member|
457 547 assert member
458 548 assert_equal @project, member.project
459 549 end
460 550 end
461 551
462 552 should "copy project specific queries" do
463 553 assert @project.valid?
464 554 assert @project.queries.empty?
465 555 assert @project.copy(@source_project)
466 556
467 557 assert_equal @source_project.queries.size, @project.queries.size
468 558 @project.queries.each do |query|
469 559 assert query
470 560 assert_equal @project, query.project
471 561 end
472 562 end
473 563
474 564 should "copy versions" do
475 565 @source_project.versions << Version.generate!
476 566 @source_project.versions << Version.generate!
477 567
478 568 assert @project.versions.empty?
479 569 assert @project.copy(@source_project)
480 570
481 571 assert_equal @source_project.versions.size, @project.versions.size
482 572 @project.versions.each do |version|
483 573 assert version
484 574 assert_equal @project, version.project
485 575 end
486 576 end
487 577
488 578 should "copy wiki" do
489 579 assert_difference 'Wiki.count' do
490 580 assert @project.copy(@source_project)
491 581 end
492 582
493 583 assert @project.wiki
494 584 assert_not_equal @source_project.wiki, @project.wiki
495 585 assert_equal "Start page", @project.wiki.start_page
496 586 end
497 587
498 588 should "copy wiki pages and content" do
499 589 assert @project.copy(@source_project)
500 590
501 591 assert @project.wiki
502 592 assert_equal 1, @project.wiki.pages.length
503 593
504 594 @project.wiki.pages.each do |wiki_page|
505 595 assert wiki_page.content
506 596 assert !@source_project.wiki.pages.include?(wiki_page)
507 597 end
508 598 end
509 599
510 600 should "copy custom fields"
511 601
512 602 should "copy issue categories" do
513 603 assert @project.copy(@source_project)
514 604
515 605 assert_equal 2, @project.issue_categories.size
516 606 @project.issue_categories.each do |issue_category|
517 607 assert !@source_project.issue_categories.include?(issue_category)
518 608 end
519 609 end
520 610
521 611 should "copy boards" do
522 612 assert @project.copy(@source_project)
523 613
524 614 assert_equal 1, @project.boards.size
525 615 @project.boards.each do |board|
526 616 assert !@source_project.boards.include?(board)
527 617 end
528 618 end
529 619
530 620 should "change the new issues to use the copied issue categories" do
531 621 issue = Issue.find(4)
532 622 issue.update_attribute(:category_id, 3)
533 623
534 624 assert @project.copy(@source_project)
535 625
536 626 @project.issues.each do |issue|
537 627 assert issue.category
538 628 assert_equal "Stock management", issue.category.name # Same name
539 629 assert_not_equal IssueCategory.find(3), issue.category # Different record
540 630 end
541 631 end
542 632
543 633 should "limit copy with :only option" do
544 634 assert @project.members.empty?
545 635 assert @project.issue_categories.empty?
546 636 assert @source_project.issues.any?
547 637
548 638 assert @project.copy(@source_project, :only => ['members', 'issue_categories'])
549 639
550 640 assert @project.members.any?
551 641 assert @project.issue_categories.any?
552 642 assert @project.issues.empty?
553 643 end
554 644
555 645 should "copy issue relations"
556 646 should "link issue relations if cross project issue relations are valid"
557 647
558 648 end
559 649
560 650 end
@@ -1,339 +1,347
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 File.dirname(__FILE__) + '/../test_helper'
19 19
20 20 class QueryTest < ActiveSupport::TestCase
21 21 fixtures :projects, :enabled_modules, :users, :members, :member_roles, :roles, :trackers, :issue_statuses, :issue_categories, :enumerations, :issues, :watchers, :custom_fields, :custom_values, :versions, :queries
22 22
23 23 def test_custom_fields_for_all_projects_should_be_available_in_global_queries
24 24 query = Query.new(:project => nil, :name => '_')
25 25 assert query.available_filters.has_key?('cf_1')
26 26 assert !query.available_filters.has_key?('cf_3')
27 27 end
28 28
29 29 def find_issues_with_query(query)
30 30 Issue.find :all,
31 31 :include => [ :assigned_to, :status, :tracker, :project, :priority ],
32 32 :conditions => query.statement
33 33 end
34
35 def test_query_should_allow_shared_versions_for_a_project_query
36 subproject_version = Version.find(4)
37 query = Query.new(:project => Project.find(1), :name => '_')
38 query.add_filter('fixed_version_id', '=', [subproject_version.id.to_s])
39
40 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IN ('4')")
41 end
34 42
35 43 def test_query_with_multiple_custom_fields
36 44 query = Query.find(1)
37 45 assert query.valid?
38 46 assert query.statement.include?("#{CustomValue.table_name}.value IN ('MySQL')")
39 47 issues = find_issues_with_query(query)
40 48 assert_equal 1, issues.length
41 49 assert_equal Issue.find(3), issues.first
42 50 end
43 51
44 52 def test_operator_none
45 53 query = Query.new(:project => Project.find(1), :name => '_')
46 54 query.add_filter('fixed_version_id', '!*', [''])
47 55 query.add_filter('cf_1', '!*', [''])
48 56 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IS NULL")
49 57 assert query.statement.include?("#{CustomValue.table_name}.value IS NULL OR #{CustomValue.table_name}.value = ''")
50 58 find_issues_with_query(query)
51 59 end
52 60
53 61 def test_operator_none_for_integer
54 62 query = Query.new(:project => Project.find(1), :name => '_')
55 63 query.add_filter('estimated_hours', '!*', [''])
56 64 issues = find_issues_with_query(query)
57 65 assert !issues.empty?
58 66 assert issues.all? {|i| !i.estimated_hours}
59 67 end
60 68
61 69 def test_operator_all
62 70 query = Query.new(:project => Project.find(1), :name => '_')
63 71 query.add_filter('fixed_version_id', '*', [''])
64 72 query.add_filter('cf_1', '*', [''])
65 73 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IS NOT NULL")
66 74 assert query.statement.include?("#{CustomValue.table_name}.value IS NOT NULL AND #{CustomValue.table_name}.value <> ''")
67 75 find_issues_with_query(query)
68 76 end
69 77
70 78 def test_operator_greater_than
71 79 query = Query.new(:project => Project.find(1), :name => '_')
72 80 query.add_filter('done_ratio', '>=', ['40'])
73 81 assert query.statement.include?("#{Issue.table_name}.done_ratio >= 40")
74 82 find_issues_with_query(query)
75 83 end
76 84
77 85 def test_operator_in_more_than
78 86 Issue.find(7).update_attribute(:due_date, (Date.today + 15))
79 87 query = Query.new(:project => Project.find(1), :name => '_')
80 88 query.add_filter('due_date', '>t+', ['15'])
81 89 issues = find_issues_with_query(query)
82 90 assert !issues.empty?
83 91 issues.each {|issue| assert(issue.due_date >= (Date.today + 15))}
84 92 end
85 93
86 94 def test_operator_in_less_than
87 95 query = Query.new(:project => Project.find(1), :name => '_')
88 96 query.add_filter('due_date', '<t+', ['15'])
89 97 issues = find_issues_with_query(query)
90 98 assert !issues.empty?
91 99 issues.each {|issue| assert(issue.due_date >= Date.today && issue.due_date <= (Date.today + 15))}
92 100 end
93 101
94 102 def test_operator_less_than_ago
95 103 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
96 104 query = Query.new(:project => Project.find(1), :name => '_')
97 105 query.add_filter('due_date', '>t-', ['3'])
98 106 issues = find_issues_with_query(query)
99 107 assert !issues.empty?
100 108 issues.each {|issue| assert(issue.due_date >= (Date.today - 3) && issue.due_date <= Date.today)}
101 109 end
102 110
103 111 def test_operator_more_than_ago
104 112 Issue.find(7).update_attribute(:due_date, (Date.today - 10))
105 113 query = Query.new(:project => Project.find(1), :name => '_')
106 114 query.add_filter('due_date', '<t-', ['10'])
107 115 assert query.statement.include?("#{Issue.table_name}.due_date <=")
108 116 issues = find_issues_with_query(query)
109 117 assert !issues.empty?
110 118 issues.each {|issue| assert(issue.due_date <= (Date.today - 10))}
111 119 end
112 120
113 121 def test_operator_in
114 122 Issue.find(7).update_attribute(:due_date, (Date.today + 2))
115 123 query = Query.new(:project => Project.find(1), :name => '_')
116 124 query.add_filter('due_date', 't+', ['2'])
117 125 issues = find_issues_with_query(query)
118 126 assert !issues.empty?
119 127 issues.each {|issue| assert_equal((Date.today + 2), issue.due_date)}
120 128 end
121 129
122 130 def test_operator_ago
123 131 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
124 132 query = Query.new(:project => Project.find(1), :name => '_')
125 133 query.add_filter('due_date', 't-', ['3'])
126 134 issues = find_issues_with_query(query)
127 135 assert !issues.empty?
128 136 issues.each {|issue| assert_equal((Date.today - 3), issue.due_date)}
129 137 end
130 138
131 139 def test_operator_today
132 140 query = Query.new(:project => Project.find(1), :name => '_')
133 141 query.add_filter('due_date', 't', [''])
134 142 issues = find_issues_with_query(query)
135 143 assert !issues.empty?
136 144 issues.each {|issue| assert_equal Date.today, issue.due_date}
137 145 end
138 146
139 147 def test_operator_this_week_on_date
140 148 query = Query.new(:project => Project.find(1), :name => '_')
141 149 query.add_filter('due_date', 'w', [''])
142 150 find_issues_with_query(query)
143 151 end
144 152
145 153 def test_operator_this_week_on_datetime
146 154 query = Query.new(:project => Project.find(1), :name => '_')
147 155 query.add_filter('created_on', 'w', [''])
148 156 find_issues_with_query(query)
149 157 end
150 158
151 159 def test_operator_contains
152 160 query = Query.new(:project => Project.find(1), :name => '_')
153 161 query.add_filter('subject', '~', ['uNable'])
154 162 assert query.statement.include?("LOWER(#{Issue.table_name}.subject) LIKE '%unable%'")
155 163 result = find_issues_with_query(query)
156 164 assert result.empty?
157 165 result.each {|issue| assert issue.subject.downcase.include?('unable') }
158 166 end
159 167
160 168 def test_operator_does_not_contains
161 169 query = Query.new(:project => Project.find(1), :name => '_')
162 170 query.add_filter('subject', '!~', ['uNable'])
163 171 assert query.statement.include?("LOWER(#{Issue.table_name}.subject) NOT LIKE '%unable%'")
164 172 find_issues_with_query(query)
165 173 end
166 174
167 175 def test_filter_watched_issues
168 176 User.current = User.find(1)
169 177 query = Query.new(:name => '_', :filters => { 'watcher_id' => {:operator => '=', :values => ['me']}})
170 178 result = find_issues_with_query(query)
171 179 assert_not_nil result
172 180 assert !result.empty?
173 181 assert_equal Issue.visible.watched_by(User.current).sort_by(&:id), result.sort_by(&:id)
174 182 User.current = nil
175 183 end
176 184
177 185 def test_filter_unwatched_issues
178 186 User.current = User.find(1)
179 187 query = Query.new(:name => '_', :filters => { 'watcher_id' => {:operator => '!', :values => ['me']}})
180 188 result = find_issues_with_query(query)
181 189 assert_not_nil result
182 190 assert !result.empty?
183 191 assert_equal((Issue.visible - Issue.watched_by(User.current)).sort_by(&:id).size, result.sort_by(&:id).size)
184 192 User.current = nil
185 193 end
186 194
187 195 def test_default_columns
188 196 q = Query.new
189 197 assert !q.columns.empty?
190 198 end
191 199
192 200 def test_set_column_names
193 201 q = Query.new
194 202 q.column_names = ['tracker', :subject, '', 'unknonw_column']
195 203 assert_equal [:tracker, :subject], q.columns.collect {|c| c.name}
196 204 c = q.columns.first
197 205 assert q.has_column?(c)
198 206 end
199 207
200 208 def test_groupable_columns_should_include_custom_fields
201 209 q = Query.new
202 210 assert q.groupable_columns.detect {|c| c.is_a? QueryCustomFieldColumn}
203 211 end
204 212
205 213 def test_default_sort
206 214 q = Query.new
207 215 assert_equal [], q.sort_criteria
208 216 end
209 217
210 218 def test_set_sort_criteria_with_hash
211 219 q = Query.new
212 220 q.sort_criteria = {'0' => ['priority', 'desc'], '2' => ['tracker']}
213 221 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
214 222 end
215 223
216 224 def test_set_sort_criteria_with_array
217 225 q = Query.new
218 226 q.sort_criteria = [['priority', 'desc'], 'tracker']
219 227 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
220 228 end
221 229
222 230 def test_create_query_with_sort
223 231 q = Query.new(:name => 'Sorted')
224 232 q.sort_criteria = [['priority', 'desc'], 'tracker']
225 233 assert q.save
226 234 q.reload
227 235 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
228 236 end
229 237
230 238 def test_sort_by_string_custom_field_asc
231 239 q = Query.new
232 240 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'string' }
233 241 assert c
234 242 assert c.sortable
235 243 issues = Issue.find :all,
236 244 :include => [ :assigned_to, :status, :tracker, :project, :priority ],
237 245 :conditions => q.statement,
238 246 :order => "#{c.sortable} ASC"
239 247 values = issues.collect {|i| i.custom_value_for(c.custom_field).to_s}
240 248 assert !values.empty?
241 249 assert_equal values.sort, values
242 250 end
243 251
244 252 def test_sort_by_string_custom_field_desc
245 253 q = Query.new
246 254 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'string' }
247 255 assert c
248 256 assert c.sortable
249 257 issues = Issue.find :all,
250 258 :include => [ :assigned_to, :status, :tracker, :project, :priority ],
251 259 :conditions => q.statement,
252 260 :order => "#{c.sortable} DESC"
253 261 values = issues.collect {|i| i.custom_value_for(c.custom_field).to_s}
254 262 assert !values.empty?
255 263 assert_equal values.sort.reverse, values
256 264 end
257 265
258 266 def test_sort_by_float_custom_field_asc
259 267 q = Query.new
260 268 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'float' }
261 269 assert c
262 270 assert c.sortable
263 271 issues = Issue.find :all,
264 272 :include => [ :assigned_to, :status, :tracker, :project, :priority ],
265 273 :conditions => q.statement,
266 274 :order => "#{c.sortable} ASC"
267 275 values = issues.collect {|i| begin; Kernel.Float(i.custom_value_for(c.custom_field).to_s); rescue; nil; end}.compact
268 276 assert !values.empty?
269 277 assert_equal values.sort, values
270 278 end
271 279
272 280 def test_invalid_query_should_raise_query_statement_invalid_error
273 281 q = Query.new
274 282 assert_raise Query::StatementInvalid do
275 283 q.issues(:conditions => "foo = 1")
276 284 end
277 285 end
278 286
279 287 def test_issue_count_by_association_group
280 288 q = Query.new(:name => '_', :group_by => 'assigned_to')
281 289 count_by_group = q.issue_count_by_group
282 290 assert_kind_of Hash, count_by_group
283 291 assert_equal %w(NilClass User), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
284 292 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
285 293 assert count_by_group.has_key?(User.find(3))
286 294 end
287 295
288 296 def test_issue_count_by_list_custom_field_group
289 297 q = Query.new(:name => '_', :group_by => 'cf_1')
290 298 count_by_group = q.issue_count_by_group
291 299 assert_kind_of Hash, count_by_group
292 300 assert_equal %w(NilClass String), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
293 301 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
294 302 assert count_by_group.has_key?('MySQL')
295 303 end
296 304
297 305 def test_issue_count_by_date_custom_field_group
298 306 q = Query.new(:name => '_', :group_by => 'cf_8')
299 307 count_by_group = q.issue_count_by_group
300 308 assert_kind_of Hash, count_by_group
301 309 assert_equal %w(Date NilClass), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
302 310 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
303 311 end
304 312
305 313 def test_label_for
306 314 q = Query.new
307 315 assert_equal 'assigned_to', q.label_for('assigned_to_id')
308 316 end
309 317
310 318 def test_editable_by
311 319 admin = User.find(1)
312 320 manager = User.find(2)
313 321 developer = User.find(3)
314 322
315 323 # Public query on project 1
316 324 q = Query.find(1)
317 325 assert q.editable_by?(admin)
318 326 assert q.editable_by?(manager)
319 327 assert !q.editable_by?(developer)
320 328
321 329 # Private query on project 1
322 330 q = Query.find(2)
323 331 assert q.editable_by?(admin)
324 332 assert !q.editable_by?(manager)
325 333 assert q.editable_by?(developer)
326 334
327 335 # Private query for all projects
328 336 q = Query.find(3)
329 337 assert q.editable_by?(admin)
330 338 assert !q.editable_by?(manager)
331 339 assert q.editable_by?(developer)
332 340
333 341 # Public query for all projects
334 342 q = Query.find(4)
335 343 assert q.editable_by?(admin)
336 344 assert !q.editable_by?(manager)
337 345 assert !q.editable_by?(developer)
338 346 end
339 347 end
@@ -1,121 +1,156
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 File.dirname(__FILE__) + '/../test_helper'
19 19
20 20 class VersionTest < ActiveSupport::TestCase
21 21 fixtures :projects, :users, :issues, :issue_statuses, :trackers, :enumerations, :versions
22 22
23 23 def setup
24 24 end
25 25
26 26 def test_create
27 27 v = Version.new(:project => Project.find(1), :name => '1.1', :effective_date => '2011-03-25')
28 28 assert v.save
29 29 assert_equal 'open', v.status
30 30 end
31 31
32 32 def test_invalid_effective_date_validation
33 33 v = Version.new(:project => Project.find(1), :name => '1.1', :effective_date => '99999-01-01')
34 34 assert !v.save
35 35 assert_equal I18n.translate('activerecord.errors.messages.not_a_date'), v.errors.on(:effective_date)
36 36 end
37 37
38 38 def test_progress_should_be_0_with_no_assigned_issues
39 39 project = Project.find(1)
40 40 v = Version.create!(:project => project, :name => 'Progress')
41 41 assert_equal 0, v.completed_pourcent
42 42 assert_equal 0, v.closed_pourcent
43 43 end
44 44
45 45 def test_progress_should_be_0_with_unbegun_assigned_issues
46 46 project = Project.find(1)
47 47 v = Version.create!(:project => project, :name => 'Progress')
48 48 add_issue(v)
49 49 add_issue(v, :done_ratio => 0)
50 50 assert_progress_equal 0, v.completed_pourcent
51 51 assert_progress_equal 0, v.closed_pourcent
52 52 end
53 53
54 54 def test_progress_should_be_100_with_closed_assigned_issues
55 55 project = Project.find(1)
56 56 status = IssueStatus.find(:first, :conditions => {:is_closed => true})
57 57 v = Version.create!(:project => project, :name => 'Progress')
58 58 add_issue(v, :status => status)
59 59 add_issue(v, :status => status, :done_ratio => 20)
60 60 add_issue(v, :status => status, :done_ratio => 70, :estimated_hours => 25)
61 61 add_issue(v, :status => status, :estimated_hours => 15)
62 62 assert_progress_equal 100.0, v.completed_pourcent
63 63 assert_progress_equal 100.0, v.closed_pourcent
64 64 end
65 65
66 66 def test_progress_should_consider_done_ratio_of_open_assigned_issues
67 67 project = Project.find(1)
68 68 v = Version.create!(:project => project, :name => 'Progress')
69 69 add_issue(v)
70 70 add_issue(v, :done_ratio => 20)
71 71 add_issue(v, :done_ratio => 70)
72 72 assert_progress_equal (0.0 + 20.0 + 70.0)/3, v.completed_pourcent
73 73 assert_progress_equal 0, v.closed_pourcent
74 74 end
75 75
76 76 def test_progress_should_consider_closed_issues_as_completed
77 77 project = Project.find(1)
78 78 v = Version.create!(:project => project, :name => 'Progress')
79 79 add_issue(v)
80 80 add_issue(v, :done_ratio => 20)
81 81 add_issue(v, :status => IssueStatus.find(:first, :conditions => {:is_closed => true}))
82 82 assert_progress_equal (0.0 + 20.0 + 100.0)/3, v.completed_pourcent
83 83 assert_progress_equal (100.0)/3, v.closed_pourcent
84 84 end
85 85
86 86 def test_progress_should_consider_estimated_hours_to_weigth_issues
87 87 project = Project.find(1)
88 88 v = Version.create!(:project => project, :name => 'Progress')
89 89 add_issue(v, :estimated_hours => 10)
90 90 add_issue(v, :estimated_hours => 20, :done_ratio => 30)
91 91 add_issue(v, :estimated_hours => 40, :done_ratio => 10)
92 92 add_issue(v, :estimated_hours => 25, :status => IssueStatus.find(:first, :conditions => {:is_closed => true}))
93 93 assert_progress_equal (10.0*0 + 20.0*0.3 + 40*0.1 + 25.0*1)/95.0*100, v.completed_pourcent
94 94 assert_progress_equal 25.0/95.0*100, v.closed_pourcent
95 95 end
96 96
97 97 def test_progress_should_consider_average_estimated_hours_to_weigth_unestimated_issues
98 98 project = Project.find(1)
99 99 v = Version.create!(:project => project, :name => 'Progress')
100 100 add_issue(v, :done_ratio => 20)
101 101 add_issue(v, :status => IssueStatus.find(:first, :conditions => {:is_closed => true}))
102 102 add_issue(v, :estimated_hours => 10, :done_ratio => 30)
103 103 add_issue(v, :estimated_hours => 40, :done_ratio => 10)
104 104 assert_progress_equal (25.0*0.2 + 25.0*1 + 10.0*0.3 + 40.0*0.1)/100.0*100, v.completed_pourcent
105 105 assert_progress_equal 25.0/100.0*100, v.closed_pourcent
106 106 end
107
108 test "should update all issue's fixed_version associations in case the hierarchy changed XXX" do
109 User.current = User.find(1) # Need the admin's permissions
110
111 @version = Version.find(7)
112 # Separate hierarchy
113 project_1_issue = Issue.find(1)
114 project_1_issue.fixed_version = @version
115 assert project_1_issue.save, project_1_issue.errors.full_messages
116
117 project_5_issue = Issue.find(6)
118 project_5_issue.fixed_version = @version
119 assert project_5_issue.save
120
121 # Project
122 project_2_issue = Issue.find(4)
123 project_2_issue.fixed_version = @version
124 assert project_2_issue.save
125
126 # Update the sharing
127 @version.sharing = 'none'
128 assert @version.save
129
130 # Project 1 now out of the shared scope
131 project_1_issue.reload
132 assert_equal nil, project_1_issue.fixed_version, "Fixed version is still set after changing the Version's sharing"
133
134 # Project 5 now out of the shared scope
135 project_5_issue.reload
136 assert_equal nil, project_5_issue.fixed_version, "Fixed version is still set after changing the Version's sharing"
137
138 # Project 2 issue remains
139 project_2_issue.reload
140 assert_equal @version, project_2_issue.fixed_version
141 end
107 142
108 143 private
109 144
110 145 def add_issue(version, attributes={})
111 146 Issue.create!({:project => version.project,
112 147 :fixed_version => version,
113 148 :subject => 'Test',
114 149 :author => User.find(:first),
115 150 :tracker => version.project.trackers.find(:first)}.merge(attributes))
116 151 end
117 152
118 153 def assert_progress_equal(expected_float, actual_float, message="")
119 154 assert_in_delta(expected_float, actual_float, 0.000001, message="")
120 155 end
121 156 end
General Comments 0
You need to be logged in to leave comments. Login now