##// END OF EJS Templates
Adds an issues visibility level on roles (#7412)....
Jean-Philippe Lang -
r5296:aa0d01b3d9f5
parent child
Show More
@@ -0,0 +1,9
1 class AddRolesIssuesVisibility < ActiveRecord::Migration
2 def self.up
3 add_column :roles, :issues_visibility, :string, :limit => 30, :default => 'default', :null => false
4 end
5
6 def self.down
7 remove_column :roles, :issues_visibility
8 end
9 end
@@ -1,482 +1,486
1 # redMine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require 'uri'
18 require 'uri'
19 require 'cgi'
19 require 'cgi'
20
20
21 class ApplicationController < ActionController::Base
21 class ApplicationController < ActionController::Base
22 include Redmine::I18n
22 include Redmine::I18n
23
23
24 layout 'base'
24 layout 'base'
25 exempt_from_layout 'builder', 'rsb'
25 exempt_from_layout 'builder', 'rsb'
26
26
27 # Remove broken cookie after upgrade from 0.8.x (#4292)
27 # Remove broken cookie after upgrade from 0.8.x (#4292)
28 # See https://rails.lighthouseapp.com/projects/8994/tickets/3360
28 # See https://rails.lighthouseapp.com/projects/8994/tickets/3360
29 # TODO: remove it when Rails is fixed
29 # TODO: remove it when Rails is fixed
30 before_filter :delete_broken_cookies
30 before_filter :delete_broken_cookies
31 def delete_broken_cookies
31 def delete_broken_cookies
32 if cookies['_redmine_session'] && cookies['_redmine_session'] !~ /--/
32 if cookies['_redmine_session'] && cookies['_redmine_session'] !~ /--/
33 cookies.delete '_redmine_session'
33 cookies.delete '_redmine_session'
34 redirect_to home_path
34 redirect_to home_path
35 return false
35 return false
36 end
36 end
37 end
37 end
38
38
39 before_filter :user_setup, :check_if_login_required, :set_localization
39 before_filter :user_setup, :check_if_login_required, :set_localization
40 filter_parameter_logging :password
40 filter_parameter_logging :password
41 protect_from_forgery
41 protect_from_forgery
42
42
43 rescue_from ActionController::InvalidAuthenticityToken, :with => :invalid_authenticity_token
43 rescue_from ActionController::InvalidAuthenticityToken, :with => :invalid_authenticity_token
44
44
45 include Redmine::Search::Controller
45 include Redmine::Search::Controller
46 include Redmine::MenuManager::MenuController
46 include Redmine::MenuManager::MenuController
47 helper Redmine::MenuManager::MenuHelper
47 helper Redmine::MenuManager::MenuHelper
48
48
49 Redmine::Scm::Base.all.each do |scm|
49 Redmine::Scm::Base.all.each do |scm|
50 require_dependency "repository/#{scm.underscore}"
50 require_dependency "repository/#{scm.underscore}"
51 end
51 end
52
52
53 def user_setup
53 def user_setup
54 # Check the settings cache for each request
54 # Check the settings cache for each request
55 Setting.check_cache
55 Setting.check_cache
56 # Find the current user
56 # Find the current user
57 User.current = find_current_user
57 User.current = find_current_user
58 end
58 end
59
59
60 # Returns the current user or nil if no user is logged in
60 # Returns the current user or nil if no user is logged in
61 # and starts a session if needed
61 # and starts a session if needed
62 def find_current_user
62 def find_current_user
63 if session[:user_id]
63 if session[:user_id]
64 # existing session
64 # existing session
65 (User.active.find(session[:user_id]) rescue nil)
65 (User.active.find(session[:user_id]) rescue nil)
66 elsif cookies[:autologin] && Setting.autologin?
66 elsif cookies[:autologin] && Setting.autologin?
67 # auto-login feature starts a new session
67 # auto-login feature starts a new session
68 user = User.try_to_autologin(cookies[:autologin])
68 user = User.try_to_autologin(cookies[:autologin])
69 session[:user_id] = user.id if user
69 session[:user_id] = user.id if user
70 user
70 user
71 elsif params[:format] == 'atom' && params[:key] && accept_key_auth_actions.include?(params[:action])
71 elsif params[:format] == 'atom' && params[:key] && accept_key_auth_actions.include?(params[:action])
72 # RSS key authentication does not start a session
72 # RSS key authentication does not start a session
73 User.find_by_rss_key(params[:key])
73 User.find_by_rss_key(params[:key])
74 elsif Setting.rest_api_enabled? && api_request?
74 elsif Setting.rest_api_enabled? && api_request?
75 if (key = api_key_from_request) && accept_key_auth_actions.include?(params[:action])
75 if (key = api_key_from_request) && accept_key_auth_actions.include?(params[:action])
76 # Use API key
76 # Use API key
77 User.find_by_api_key(key)
77 User.find_by_api_key(key)
78 else
78 else
79 # HTTP Basic, either username/password or API key/random
79 # HTTP Basic, either username/password or API key/random
80 authenticate_with_http_basic do |username, password|
80 authenticate_with_http_basic do |username, password|
81 User.try_to_login(username, password) || User.find_by_api_key(username)
81 User.try_to_login(username, password) || User.find_by_api_key(username)
82 end
82 end
83 end
83 end
84 end
84 end
85 end
85 end
86
86
87 # Sets the logged in user
87 # Sets the logged in user
88 def logged_user=(user)
88 def logged_user=(user)
89 reset_session
89 reset_session
90 if user && user.is_a?(User)
90 if user && user.is_a?(User)
91 User.current = user
91 User.current = user
92 session[:user_id] = user.id
92 session[:user_id] = user.id
93 else
93 else
94 User.current = User.anonymous
94 User.current = User.anonymous
95 end
95 end
96 end
96 end
97
97
98 # check if login is globally required to access the application
98 # check if login is globally required to access the application
99 def check_if_login_required
99 def check_if_login_required
100 # no check needed if user is already logged in
100 # no check needed if user is already logged in
101 return true if User.current.logged?
101 return true if User.current.logged?
102 require_login if Setting.login_required?
102 require_login if Setting.login_required?
103 end
103 end
104
104
105 def set_localization
105 def set_localization
106 lang = nil
106 lang = nil
107 if User.current.logged?
107 if User.current.logged?
108 lang = find_language(User.current.language)
108 lang = find_language(User.current.language)
109 end
109 end
110 if lang.nil? && request.env['HTTP_ACCEPT_LANGUAGE']
110 if lang.nil? && request.env['HTTP_ACCEPT_LANGUAGE']
111 accept_lang = parse_qvalues(request.env['HTTP_ACCEPT_LANGUAGE']).first
111 accept_lang = parse_qvalues(request.env['HTTP_ACCEPT_LANGUAGE']).first
112 if !accept_lang.blank?
112 if !accept_lang.blank?
113 accept_lang = accept_lang.downcase
113 accept_lang = accept_lang.downcase
114 lang = find_language(accept_lang) || find_language(accept_lang.split('-').first)
114 lang = find_language(accept_lang) || find_language(accept_lang.split('-').first)
115 end
115 end
116 end
116 end
117 lang ||= Setting.default_language
117 lang ||= Setting.default_language
118 set_language_if_valid(lang)
118 set_language_if_valid(lang)
119 end
119 end
120
120
121 def require_login
121 def require_login
122 if !User.current.logged?
122 if !User.current.logged?
123 # Extract only the basic url parameters on non-GET requests
123 # Extract only the basic url parameters on non-GET requests
124 if request.get?
124 if request.get?
125 url = url_for(params)
125 url = url_for(params)
126 else
126 else
127 url = url_for(:controller => params[:controller], :action => params[:action], :id => params[:id], :project_id => params[:project_id])
127 url = url_for(:controller => params[:controller], :action => params[:action], :id => params[:id], :project_id => params[:project_id])
128 end
128 end
129 respond_to do |format|
129 respond_to do |format|
130 format.html { redirect_to :controller => "account", :action => "login", :back_url => url }
130 format.html { redirect_to :controller => "account", :action => "login", :back_url => url }
131 format.atom { redirect_to :controller => "account", :action => "login", :back_url => url }
131 format.atom { redirect_to :controller => "account", :action => "login", :back_url => url }
132 format.xml { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
132 format.xml { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
133 format.js { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
133 format.js { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
134 format.json { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
134 format.json { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
135 end
135 end
136 return false
136 return false
137 end
137 end
138 true
138 true
139 end
139 end
140
140
141 def require_admin
141 def require_admin
142 return unless require_login
142 return unless require_login
143 if !User.current.admin?
143 if !User.current.admin?
144 render_403
144 render_403
145 return false
145 return false
146 end
146 end
147 true
147 true
148 end
148 end
149
149
150 def deny_access
150 def deny_access
151 User.current.logged? ? render_403 : require_login
151 User.current.logged? ? render_403 : require_login
152 end
152 end
153
153
154 # Authorize the user for the requested action
154 # Authorize the user for the requested action
155 def authorize(ctrl = params[:controller], action = params[:action], global = false)
155 def authorize(ctrl = params[:controller], action = params[:action], global = false)
156 allowed = User.current.allowed_to?({:controller => ctrl, :action => action}, @project || @projects, :global => global)
156 allowed = User.current.allowed_to?({:controller => ctrl, :action => action}, @project || @projects, :global => global)
157 if allowed
157 if allowed
158 true
158 true
159 else
159 else
160 if @project && @project.archived?
160 if @project && @project.archived?
161 render_403 :message => :notice_not_authorized_archived_project
161 render_403 :message => :notice_not_authorized_archived_project
162 else
162 else
163 deny_access
163 deny_access
164 end
164 end
165 end
165 end
166 end
166 end
167
167
168 # Authorize the user for the requested action outside a project
168 # Authorize the user for the requested action outside a project
169 def authorize_global(ctrl = params[:controller], action = params[:action], global = true)
169 def authorize_global(ctrl = params[:controller], action = params[:action], global = true)
170 authorize(ctrl, action, global)
170 authorize(ctrl, action, global)
171 end
171 end
172
172
173 # Find project of id params[:id]
173 # Find project of id params[:id]
174 def find_project
174 def find_project
175 @project = Project.find(params[:id])
175 @project = Project.find(params[:id])
176 rescue ActiveRecord::RecordNotFound
176 rescue ActiveRecord::RecordNotFound
177 render_404
177 render_404
178 end
178 end
179
179
180 # Find project of id params[:project_id]
180 # Find project of id params[:project_id]
181 def find_project_by_project_id
181 def find_project_by_project_id
182 @project = Project.find(params[:project_id])
182 @project = Project.find(params[:project_id])
183 rescue ActiveRecord::RecordNotFound
183 rescue ActiveRecord::RecordNotFound
184 render_404
184 render_404
185 end
185 end
186
186
187 # Find a project based on params[:project_id]
187 # Find a project based on params[:project_id]
188 # TODO: some subclasses override this, see about merging their logic
188 # TODO: some subclasses override this, see about merging their logic
189 def find_optional_project
189 def find_optional_project
190 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
190 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
191 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
191 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
192 allowed ? true : deny_access
192 allowed ? true : deny_access
193 rescue ActiveRecord::RecordNotFound
193 rescue ActiveRecord::RecordNotFound
194 render_404
194 render_404
195 end
195 end
196
196
197 # Finds and sets @project based on @object.project
197 # Finds and sets @project based on @object.project
198 def find_project_from_association
198 def find_project_from_association
199 render_404 unless @object.present?
199 render_404 unless @object.present?
200
200
201 @project = @object.project
201 @project = @object.project
202 rescue ActiveRecord::RecordNotFound
202 rescue ActiveRecord::RecordNotFound
203 render_404
203 render_404
204 end
204 end
205
205
206 def find_model_object
206 def find_model_object
207 model = self.class.read_inheritable_attribute('model_object')
207 model = self.class.read_inheritable_attribute('model_object')
208 if model
208 if model
209 @object = model.find(params[:id])
209 @object = model.find(params[:id])
210 self.instance_variable_set('@' + controller_name.singularize, @object) if @object
210 self.instance_variable_set('@' + controller_name.singularize, @object) if @object
211 end
211 end
212 rescue ActiveRecord::RecordNotFound
212 rescue ActiveRecord::RecordNotFound
213 render_404
213 render_404
214 end
214 end
215
215
216 def self.model_object(model)
216 def self.model_object(model)
217 write_inheritable_attribute('model_object', model)
217 write_inheritable_attribute('model_object', model)
218 end
218 end
219
219
220 # Filter for bulk issue operations
220 # Filter for bulk issue operations
221 def find_issues
221 def find_issues
222 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
222 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
223 raise ActiveRecord::RecordNotFound if @issues.empty?
223 raise ActiveRecord::RecordNotFound if @issues.empty?
224 if @issues.detect {|issue| !issue.visible?}
225 deny_access
226 return
227 end
224 @projects = @issues.collect(&:project).compact.uniq
228 @projects = @issues.collect(&:project).compact.uniq
225 @project = @projects.first if @projects.size == 1
229 @project = @projects.first if @projects.size == 1
226 rescue ActiveRecord::RecordNotFound
230 rescue ActiveRecord::RecordNotFound
227 render_404
231 render_404
228 end
232 end
229
233
230 # Check if project is unique before bulk operations
234 # Check if project is unique before bulk operations
231 def check_project_uniqueness
235 def check_project_uniqueness
232 unless @project
236 unless @project
233 # TODO: let users bulk edit/move/destroy issues from different projects
237 # TODO: let users bulk edit/move/destroy issues from different projects
234 render_error 'Can not bulk edit/move/destroy issues from different projects'
238 render_error 'Can not bulk edit/move/destroy issues from different projects'
235 return false
239 return false
236 end
240 end
237 end
241 end
238
242
239 # make sure that the user is a member of the project (or admin) if project is private
243 # make sure that the user is a member of the project (or admin) if project is private
240 # used as a before_filter for actions that do not require any particular permission on the project
244 # used as a before_filter for actions that do not require any particular permission on the project
241 def check_project_privacy
245 def check_project_privacy
242 if @project && @project.active?
246 if @project && @project.active?
243 if @project.is_public? || User.current.member_of?(@project) || User.current.admin?
247 if @project.is_public? || User.current.member_of?(@project) || User.current.admin?
244 true
248 true
245 else
249 else
246 User.current.logged? ? render_403 : require_login
250 User.current.logged? ? render_403 : require_login
247 end
251 end
248 else
252 else
249 @project = nil
253 @project = nil
250 render_404
254 render_404
251 false
255 false
252 end
256 end
253 end
257 end
254
258
255 def back_url
259 def back_url
256 params[:back_url] || request.env['HTTP_REFERER']
260 params[:back_url] || request.env['HTTP_REFERER']
257 end
261 end
258
262
259 def redirect_back_or_default(default)
263 def redirect_back_or_default(default)
260 back_url = CGI.unescape(params[:back_url].to_s)
264 back_url = CGI.unescape(params[:back_url].to_s)
261 if !back_url.blank?
265 if !back_url.blank?
262 begin
266 begin
263 uri = URI.parse(back_url)
267 uri = URI.parse(back_url)
264 # do not redirect user to another host or to the login or register page
268 # do not redirect user to another host or to the login or register page
265 if (uri.relative? || (uri.host == request.host)) && !uri.path.match(%r{/(login|account/register)})
269 if (uri.relative? || (uri.host == request.host)) && !uri.path.match(%r{/(login|account/register)})
266 redirect_to(back_url)
270 redirect_to(back_url)
267 return
271 return
268 end
272 end
269 rescue URI::InvalidURIError
273 rescue URI::InvalidURIError
270 # redirect to default
274 # redirect to default
271 end
275 end
272 end
276 end
273 redirect_to default
277 redirect_to default
274 end
278 end
275
279
276 def render_403(options={})
280 def render_403(options={})
277 @project = nil
281 @project = nil
278 render_error({:message => :notice_not_authorized, :status => 403}.merge(options))
282 render_error({:message => :notice_not_authorized, :status => 403}.merge(options))
279 return false
283 return false
280 end
284 end
281
285
282 def render_404(options={})
286 def render_404(options={})
283 render_error({:message => :notice_file_not_found, :status => 404}.merge(options))
287 render_error({:message => :notice_file_not_found, :status => 404}.merge(options))
284 return false
288 return false
285 end
289 end
286
290
287 # Renders an error response
291 # Renders an error response
288 def render_error(arg)
292 def render_error(arg)
289 arg = {:message => arg} unless arg.is_a?(Hash)
293 arg = {:message => arg} unless arg.is_a?(Hash)
290
294
291 @message = arg[:message]
295 @message = arg[:message]
292 @message = l(@message) if @message.is_a?(Symbol)
296 @message = l(@message) if @message.is_a?(Symbol)
293 @status = arg[:status] || 500
297 @status = arg[:status] || 500
294
298
295 respond_to do |format|
299 respond_to do |format|
296 format.html {
300 format.html {
297 render :template => 'common/error', :layout => use_layout, :status => @status
301 render :template => 'common/error', :layout => use_layout, :status => @status
298 }
302 }
299 format.atom { head @status }
303 format.atom { head @status }
300 format.xml { head @status }
304 format.xml { head @status }
301 format.js { head @status }
305 format.js { head @status }
302 format.json { head @status }
306 format.json { head @status }
303 end
307 end
304 end
308 end
305
309
306 # Picks which layout to use based on the request
310 # Picks which layout to use based on the request
307 #
311 #
308 # @return [boolean, string] name of the layout to use or false for no layout
312 # @return [boolean, string] name of the layout to use or false for no layout
309 def use_layout
313 def use_layout
310 request.xhr? ? false : 'base'
314 request.xhr? ? false : 'base'
311 end
315 end
312
316
313 def invalid_authenticity_token
317 def invalid_authenticity_token
314 if api_request?
318 if api_request?
315 logger.error "Form authenticity token is missing or is invalid. API calls must include a proper Content-type header (text/xml or text/json)."
319 logger.error "Form authenticity token is missing or is invalid. API calls must include a proper Content-type header (text/xml or text/json)."
316 end
320 end
317 render_error "Invalid form authenticity token."
321 render_error "Invalid form authenticity token."
318 end
322 end
319
323
320 def render_feed(items, options={})
324 def render_feed(items, options={})
321 @items = items || []
325 @items = items || []
322 @items.sort! {|x,y| y.event_datetime <=> x.event_datetime }
326 @items.sort! {|x,y| y.event_datetime <=> x.event_datetime }
323 @items = @items.slice(0, Setting.feeds_limit.to_i)
327 @items = @items.slice(0, Setting.feeds_limit.to_i)
324 @title = options[:title] || Setting.app_title
328 @title = options[:title] || Setting.app_title
325 render :template => "common/feed.atom.rxml", :layout => false, :content_type => 'application/atom+xml'
329 render :template => "common/feed.atom.rxml", :layout => false, :content_type => 'application/atom+xml'
326 end
330 end
327
331
328 def self.accept_key_auth(*actions)
332 def self.accept_key_auth(*actions)
329 actions = actions.flatten.map(&:to_s)
333 actions = actions.flatten.map(&:to_s)
330 write_inheritable_attribute('accept_key_auth_actions', actions)
334 write_inheritable_attribute('accept_key_auth_actions', actions)
331 end
335 end
332
336
333 def accept_key_auth_actions
337 def accept_key_auth_actions
334 self.class.read_inheritable_attribute('accept_key_auth_actions') || []
338 self.class.read_inheritable_attribute('accept_key_auth_actions') || []
335 end
339 end
336
340
337 # Returns the number of objects that should be displayed
341 # Returns the number of objects that should be displayed
338 # on the paginated list
342 # on the paginated list
339 def per_page_option
343 def per_page_option
340 per_page = nil
344 per_page = nil
341 if params[:per_page] && Setting.per_page_options_array.include?(params[:per_page].to_s.to_i)
345 if params[:per_page] && Setting.per_page_options_array.include?(params[:per_page].to_s.to_i)
342 per_page = params[:per_page].to_s.to_i
346 per_page = params[:per_page].to_s.to_i
343 session[:per_page] = per_page
347 session[:per_page] = per_page
344 elsif session[:per_page]
348 elsif session[:per_page]
345 per_page = session[:per_page]
349 per_page = session[:per_page]
346 else
350 else
347 per_page = Setting.per_page_options_array.first || 25
351 per_page = Setting.per_page_options_array.first || 25
348 end
352 end
349 per_page
353 per_page
350 end
354 end
351
355
352 # Returns offset and limit used to retrieve objects
356 # Returns offset and limit used to retrieve objects
353 # for an API response based on offset, limit and page parameters
357 # for an API response based on offset, limit and page parameters
354 def api_offset_and_limit(options=params)
358 def api_offset_and_limit(options=params)
355 if options[:offset].present?
359 if options[:offset].present?
356 offset = options[:offset].to_i
360 offset = options[:offset].to_i
357 if offset < 0
361 if offset < 0
358 offset = 0
362 offset = 0
359 end
363 end
360 end
364 end
361 limit = options[:limit].to_i
365 limit = options[:limit].to_i
362 if limit < 1
366 if limit < 1
363 limit = 25
367 limit = 25
364 elsif limit > 100
368 elsif limit > 100
365 limit = 100
369 limit = 100
366 end
370 end
367 if offset.nil? && options[:page].present?
371 if offset.nil? && options[:page].present?
368 offset = (options[:page].to_i - 1) * limit
372 offset = (options[:page].to_i - 1) * limit
369 offset = 0 if offset < 0
373 offset = 0 if offset < 0
370 end
374 end
371 offset ||= 0
375 offset ||= 0
372
376
373 [offset, limit]
377 [offset, limit]
374 end
378 end
375
379
376 # qvalues http header parser
380 # qvalues http header parser
377 # code taken from webrick
381 # code taken from webrick
378 def parse_qvalues(value)
382 def parse_qvalues(value)
379 tmp = []
383 tmp = []
380 if value
384 if value
381 parts = value.split(/,\s*/)
385 parts = value.split(/,\s*/)
382 parts.each {|part|
386 parts.each {|part|
383 if m = %r{^([^\s,]+?)(?:;\s*q=(\d+(?:\.\d+)?))?$}.match(part)
387 if m = %r{^([^\s,]+?)(?:;\s*q=(\d+(?:\.\d+)?))?$}.match(part)
384 val = m[1]
388 val = m[1]
385 q = (m[2] or 1).to_f
389 q = (m[2] or 1).to_f
386 tmp.push([val, q])
390 tmp.push([val, q])
387 end
391 end
388 }
392 }
389 tmp = tmp.sort_by{|val, q| -q}
393 tmp = tmp.sort_by{|val, q| -q}
390 tmp.collect!{|val, q| val}
394 tmp.collect!{|val, q| val}
391 end
395 end
392 return tmp
396 return tmp
393 rescue
397 rescue
394 nil
398 nil
395 end
399 end
396
400
397 # Returns a string that can be used as filename value in Content-Disposition header
401 # Returns a string that can be used as filename value in Content-Disposition header
398 def filename_for_content_disposition(name)
402 def filename_for_content_disposition(name)
399 request.env['HTTP_USER_AGENT'] =~ %r{MSIE} ? ERB::Util.url_encode(name) : name
403 request.env['HTTP_USER_AGENT'] =~ %r{MSIE} ? ERB::Util.url_encode(name) : name
400 end
404 end
401
405
402 def api_request?
406 def api_request?
403 %w(xml json).include? params[:format]
407 %w(xml json).include? params[:format]
404 end
408 end
405
409
406 # Returns the API key present in the request
410 # Returns the API key present in the request
407 def api_key_from_request
411 def api_key_from_request
408 if params[:key].present?
412 if params[:key].present?
409 params[:key]
413 params[:key]
410 elsif request.headers["X-Redmine-API-Key"].present?
414 elsif request.headers["X-Redmine-API-Key"].present?
411 request.headers["X-Redmine-API-Key"]
415 request.headers["X-Redmine-API-Key"]
412 end
416 end
413 end
417 end
414
418
415 # Renders a warning flash if obj has unsaved attachments
419 # Renders a warning flash if obj has unsaved attachments
416 def render_attachment_warning_if_needed(obj)
420 def render_attachment_warning_if_needed(obj)
417 flash[:warning] = l(:warning_attachments_not_saved, obj.unsaved_attachments.size) if obj.unsaved_attachments.present?
421 flash[:warning] = l(:warning_attachments_not_saved, obj.unsaved_attachments.size) if obj.unsaved_attachments.present?
418 end
422 end
419
423
420 # Sets the `flash` notice or error based the number of issues that did not save
424 # Sets the `flash` notice or error based the number of issues that did not save
421 #
425 #
422 # @param [Array, Issue] issues all of the saved and unsaved Issues
426 # @param [Array, Issue] issues all of the saved and unsaved Issues
423 # @param [Array, Integer] unsaved_issue_ids the issue ids that were not saved
427 # @param [Array, Integer] unsaved_issue_ids the issue ids that were not saved
424 def set_flash_from_bulk_issue_save(issues, unsaved_issue_ids)
428 def set_flash_from_bulk_issue_save(issues, unsaved_issue_ids)
425 if unsaved_issue_ids.empty?
429 if unsaved_issue_ids.empty?
426 flash[:notice] = l(:notice_successful_update) unless issues.empty?
430 flash[:notice] = l(:notice_successful_update) unless issues.empty?
427 else
431 else
428 flash[:error] = l(:notice_failed_to_save_issues,
432 flash[:error] = l(:notice_failed_to_save_issues,
429 :count => unsaved_issue_ids.size,
433 :count => unsaved_issue_ids.size,
430 :total => issues.size,
434 :total => issues.size,
431 :ids => '#' + unsaved_issue_ids.join(', #'))
435 :ids => '#' + unsaved_issue_ids.join(', #'))
432 end
436 end
433 end
437 end
434
438
435 # Rescues an invalid query statement. Just in case...
439 # Rescues an invalid query statement. Just in case...
436 def query_statement_invalid(exception)
440 def query_statement_invalid(exception)
437 logger.error "Query::StatementInvalid: #{exception.message}" if logger
441 logger.error "Query::StatementInvalid: #{exception.message}" if logger
438 session.delete(:query)
442 session.delete(:query)
439 sort_clear if respond_to?(:sort_clear)
443 sort_clear if respond_to?(:sort_clear)
440 render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
444 render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
441 end
445 end
442
446
443 # Converts the errors on an ActiveRecord object into a common JSON format
447 # Converts the errors on an ActiveRecord object into a common JSON format
444 def object_errors_to_json(object)
448 def object_errors_to_json(object)
445 object.errors.collect do |attribute, error|
449 object.errors.collect do |attribute, error|
446 { attribute => error }
450 { attribute => error }
447 end.to_json
451 end.to_json
448 end
452 end
449
453
450 # Renders API response on validation failure
454 # Renders API response on validation failure
451 def render_validation_errors(object)
455 def render_validation_errors(object)
452 options = { :status => :unprocessable_entity, :layout => false }
456 options = { :status => :unprocessable_entity, :layout => false }
453 options.merge!(case params[:format]
457 options.merge!(case params[:format]
454 when 'xml'; { :xml => object.errors }
458 when 'xml'; { :xml => object.errors }
455 when 'json'; { :json => {'errors' => object.errors} } # ActiveResource client compliance
459 when 'json'; { :json => {'errors' => object.errors} } # ActiveResource client compliance
456 else
460 else
457 raise "Unknown format #{params[:format]} in #render_validation_errors"
461 raise "Unknown format #{params[:format]} in #render_validation_errors"
458 end
462 end
459 )
463 )
460 render options
464 render options
461 end
465 end
462
466
463 # Overrides #default_template so that the api template
467 # Overrides #default_template so that the api template
464 # is used automatically if it exists
468 # is used automatically if it exists
465 def default_template(action_name = self.action_name)
469 def default_template(action_name = self.action_name)
466 if api_request?
470 if api_request?
467 begin
471 begin
468 return self.view_paths.find_template(default_template_name(action_name), 'api')
472 return self.view_paths.find_template(default_template_name(action_name), 'api')
469 rescue ::ActionView::MissingTemplate
473 rescue ::ActionView::MissingTemplate
470 # the api template was not found
474 # the api template was not found
471 # fallback to the default behaviour
475 # fallback to the default behaviour
472 end
476 end
473 end
477 end
474 super
478 super
475 end
479 end
476
480
477 # Overrides #pick_layout so that #render with no arguments
481 # Overrides #pick_layout so that #render with no arguments
478 # doesn't use the layout for api requests
482 # doesn't use the layout for api requests
479 def pick_layout(*args)
483 def pick_layout(*args)
480 api_request? ? nil : super
484 api_request? ? nil : super
481 end
485 end
482 end
486 end
@@ -1,325 +1,331
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class IssuesController < ApplicationController
18 class IssuesController < ApplicationController
19 menu_item :new_issue, :only => [:new, :create]
19 menu_item :new_issue, :only => [:new, :create]
20 default_search_scope :issues
20 default_search_scope :issues
21
21
22 before_filter :find_issue, :only => [:show, :edit, :update]
22 before_filter :find_issue, :only => [:show, :edit, :update]
23 before_filter :find_issues, :only => [:bulk_edit, :bulk_update, :move, :perform_move, :destroy]
23 before_filter :find_issues, :only => [:bulk_edit, :bulk_update, :move, :perform_move, :destroy]
24 before_filter :check_project_uniqueness, :only => [:move, :perform_move]
24 before_filter :check_project_uniqueness, :only => [:move, :perform_move]
25 before_filter :find_project, :only => [:new, :create]
25 before_filter :find_project, :only => [:new, :create]
26 before_filter :authorize, :except => [:index]
26 before_filter :authorize, :except => [:index]
27 before_filter :find_optional_project, :only => [:index]
27 before_filter :find_optional_project, :only => [:index]
28 before_filter :check_for_default_issue_status, :only => [:new, :create]
28 before_filter :check_for_default_issue_status, :only => [:new, :create]
29 before_filter :build_new_issue_from_params, :only => [:new, :create]
29 before_filter :build_new_issue_from_params, :only => [:new, :create]
30 accept_key_auth :index, :show, :create, :update, :destroy
30 accept_key_auth :index, :show, :create, :update, :destroy
31
31
32 rescue_from Query::StatementInvalid, :with => :query_statement_invalid
32 rescue_from Query::StatementInvalid, :with => :query_statement_invalid
33
33
34 helper :journals
34 helper :journals
35 helper :projects
35 helper :projects
36 include ProjectsHelper
36 include ProjectsHelper
37 helper :custom_fields
37 helper :custom_fields
38 include CustomFieldsHelper
38 include CustomFieldsHelper
39 helper :issue_relations
39 helper :issue_relations
40 include IssueRelationsHelper
40 include IssueRelationsHelper
41 helper :watchers
41 helper :watchers
42 include WatchersHelper
42 include WatchersHelper
43 helper :attachments
43 helper :attachments
44 include AttachmentsHelper
44 include AttachmentsHelper
45 helper :queries
45 helper :queries
46 include QueriesHelper
46 include QueriesHelper
47 helper :repositories
47 helper :repositories
48 include RepositoriesHelper
48 include RepositoriesHelper
49 helper :sort
49 helper :sort
50 include SortHelper
50 include SortHelper
51 include IssuesHelper
51 include IssuesHelper
52 helper :timelog
52 helper :timelog
53 helper :gantt
53 helper :gantt
54 include Redmine::Export::PDF
54 include Redmine::Export::PDF
55
55
56 verify :method => [:post, :delete],
56 verify :method => [:post, :delete],
57 :only => :destroy,
57 :only => :destroy,
58 :render => { :nothing => true, :status => :method_not_allowed }
58 :render => { :nothing => true, :status => :method_not_allowed }
59
59
60 verify :method => :post, :only => :create, :render => {:nothing => true, :status => :method_not_allowed }
60 verify :method => :post, :only => :create, :render => {:nothing => true, :status => :method_not_allowed }
61 verify :method => :post, :only => :bulk_update, :render => {:nothing => true, :status => :method_not_allowed }
61 verify :method => :post, :only => :bulk_update, :render => {:nothing => true, :status => :method_not_allowed }
62 verify :method => :put, :only => :update, :render => {:nothing => true, :status => :method_not_allowed }
62 verify :method => :put, :only => :update, :render => {:nothing => true, :status => :method_not_allowed }
63
63
64 def index
64 def index
65 retrieve_query
65 retrieve_query
66 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
66 sort_init(@query.sort_criteria.empty? ? [['id', 'desc']] : @query.sort_criteria)
67 sort_update(@query.sortable_columns)
67 sort_update(@query.sortable_columns)
68
68
69 if @query.valid?
69 if @query.valid?
70 case params[:format]
70 case params[:format]
71 when 'csv', 'pdf'
71 when 'csv', 'pdf'
72 @limit = Setting.issues_export_limit.to_i
72 @limit = Setting.issues_export_limit.to_i
73 when 'atom'
73 when 'atom'
74 @limit = Setting.feeds_limit.to_i
74 @limit = Setting.feeds_limit.to_i
75 when 'xml', 'json'
75 when 'xml', 'json'
76 @offset, @limit = api_offset_and_limit
76 @offset, @limit = api_offset_and_limit
77 else
77 else
78 @limit = per_page_option
78 @limit = per_page_option
79 end
79 end
80
80
81 @issue_count = @query.issue_count
81 @issue_count = @query.issue_count
82 @issue_pages = Paginator.new self, @issue_count, @limit, params['page']
82 @issue_pages = Paginator.new self, @issue_count, @limit, params['page']
83 @offset ||= @issue_pages.current.offset
83 @offset ||= @issue_pages.current.offset
84 @issues = @query.issues(:include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
84 @issues = @query.issues(:include => [:assigned_to, :tracker, :priority, :category, :fixed_version],
85 :order => sort_clause,
85 :order => sort_clause,
86 :offset => @offset,
86 :offset => @offset,
87 :limit => @limit)
87 :limit => @limit)
88 @issue_count_by_group = @query.issue_count_by_group
88 @issue_count_by_group = @query.issue_count_by_group
89
89
90 respond_to do |format|
90 respond_to do |format|
91 format.html { render :template => 'issues/index.rhtml', :layout => !request.xhr? }
91 format.html { render :template => 'issues/index.rhtml', :layout => !request.xhr? }
92 format.api
92 format.api
93 format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
93 format.atom { render_feed(@issues, :title => "#{@project || Setting.app_title}: #{l(:label_issue_plural)}") }
94 format.csv { send_data(issues_to_csv(@issues, @project), :type => 'text/csv; header=present', :filename => 'export.csv') }
94 format.csv { send_data(issues_to_csv(@issues, @project), :type => 'text/csv; header=present', :filename => 'export.csv') }
95 format.pdf { send_data(issues_to_pdf(@issues, @project, @query), :type => 'application/pdf', :filename => 'export.pdf') }
95 format.pdf { send_data(issues_to_pdf(@issues, @project, @query), :type => 'application/pdf', :filename => 'export.pdf') }
96 end
96 end
97 else
97 else
98 # Send html if the query is not valid
98 # Send html if the query is not valid
99 render(:template => 'issues/index.rhtml', :layout => !request.xhr?)
99 render(:template => 'issues/index.rhtml', :layout => !request.xhr?)
100 end
100 end
101 rescue ActiveRecord::RecordNotFound
101 rescue ActiveRecord::RecordNotFound
102 render_404
102 render_404
103 end
103 end
104
104
105 def show
105 def show
106 @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
106 @journals = @issue.journals.find(:all, :include => [:user, :details], :order => "#{Journal.table_name}.created_on ASC")
107 @journals.each_with_index {|j,i| j.indice = i+1}
107 @journals.each_with_index {|j,i| j.indice = i+1}
108 @journals.reverse! if User.current.wants_comments_in_reverse_order?
108 @journals.reverse! if User.current.wants_comments_in_reverse_order?
109 @changesets = @issue.changesets.visible.all
109 @changesets = @issue.changesets.visible.all
110 @changesets.reverse! if User.current.wants_comments_in_reverse_order?
110 @changesets.reverse! if User.current.wants_comments_in_reverse_order?
111 @relations = @issue.relations.select {|r| r.other_issue(@issue) && r.other_issue(@issue).visible? }
111 @relations = @issue.relations.select {|r| r.other_issue(@issue) && r.other_issue(@issue).visible? }
112 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
112 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
113 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
113 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
114 @priorities = IssuePriority.all
114 @priorities = IssuePriority.all
115 @time_entry = TimeEntry.new(:issue => @issue, :project => @issue.project)
115 @time_entry = TimeEntry.new(:issue => @issue, :project => @issue.project)
116 respond_to do |format|
116 respond_to do |format|
117 format.html { render :template => 'issues/show.rhtml' }
117 format.html { render :template => 'issues/show.rhtml' }
118 format.api
118 format.api
119 format.atom { render :template => 'journals/index', :layout => false, :content_type => 'application/atom+xml' }
119 format.atom { render :template => 'journals/index', :layout => false, :content_type => 'application/atom+xml' }
120 format.pdf { send_data(issue_to_pdf(@issue), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
120 format.pdf { send_data(issue_to_pdf(@issue), :type => 'application/pdf', :filename => "#{@project.identifier}-#{@issue.id}.pdf") }
121 end
121 end
122 end
122 end
123
123
124 # Add a new issue
124 # Add a new issue
125 # The new issue will be created from an existing one if copy_from parameter is given
125 # The new issue will be created from an existing one if copy_from parameter is given
126 def new
126 def new
127 respond_to do |format|
127 respond_to do |format|
128 format.html { render :action => 'new', :layout => !request.xhr? }
128 format.html { render :action => 'new', :layout => !request.xhr? }
129 format.js { render :partial => 'attributes' }
129 format.js { render :partial => 'attributes' }
130 end
130 end
131 end
131 end
132
132
133 def create
133 def create
134 call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
134 call_hook(:controller_issues_new_before_save, { :params => params, :issue => @issue })
135 if @issue.save
135 if @issue.save
136 attachments = Attachment.attach_files(@issue, params[:attachments])
136 attachments = Attachment.attach_files(@issue, params[:attachments])
137 render_attachment_warning_if_needed(@issue)
137 render_attachment_warning_if_needed(@issue)
138 flash[:notice] = l(:notice_successful_create)
138 flash[:notice] = l(:notice_successful_create)
139 call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
139 call_hook(:controller_issues_new_after_save, { :params => params, :issue => @issue})
140 respond_to do |format|
140 respond_to do |format|
141 format.html {
141 format.html {
142 redirect_to(params[:continue] ? { :action => 'new', :project_id => @project, :issue => {:tracker_id => @issue.tracker, :parent_issue_id => @issue.parent_issue_id}.reject {|k,v| v.nil?} } :
142 redirect_to(params[:continue] ? { :action => 'new', :project_id => @project, :issue => {:tracker_id => @issue.tracker, :parent_issue_id => @issue.parent_issue_id}.reject {|k,v| v.nil?} } :
143 { :action => 'show', :id => @issue })
143 { :action => 'show', :id => @issue })
144 }
144 }
145 format.api { render :action => 'show', :status => :created, :location => issue_url(@issue) }
145 format.api { render :action => 'show', :status => :created, :location => issue_url(@issue) }
146 end
146 end
147 return
147 return
148 else
148 else
149 respond_to do |format|
149 respond_to do |format|
150 format.html { render :action => 'new' }
150 format.html { render :action => 'new' }
151 format.api { render_validation_errors(@issue) }
151 format.api { render_validation_errors(@issue) }
152 end
152 end
153 end
153 end
154 end
154 end
155
155
156 def edit
156 def edit
157 update_issue_from_params
157 update_issue_from_params
158
158
159 @journal = @issue.current_journal
159 @journal = @issue.current_journal
160
160
161 respond_to do |format|
161 respond_to do |format|
162 format.html { }
162 format.html { }
163 format.xml { }
163 format.xml { }
164 end
164 end
165 end
165 end
166
166
167 def update
167 def update
168 update_issue_from_params
168 update_issue_from_params
169
169
170 if @issue.save_issue_with_child_records(params, @time_entry)
170 if @issue.save_issue_with_child_records(params, @time_entry)
171 render_attachment_warning_if_needed(@issue)
171 render_attachment_warning_if_needed(@issue)
172 flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
172 flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
173
173
174 respond_to do |format|
174 respond_to do |format|
175 format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
175 format.html { redirect_back_or_default({:action => 'show', :id => @issue}) }
176 format.api { head :ok }
176 format.api { head :ok }
177 end
177 end
178 else
178 else
179 render_attachment_warning_if_needed(@issue)
179 render_attachment_warning_if_needed(@issue)
180 flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
180 flash[:notice] = l(:notice_successful_update) unless @issue.current_journal.new_record?
181 @journal = @issue.current_journal
181 @journal = @issue.current_journal
182
182
183 respond_to do |format|
183 respond_to do |format|
184 format.html { render :action => 'edit' }
184 format.html { render :action => 'edit' }
185 format.api { render_validation_errors(@issue) }
185 format.api { render_validation_errors(@issue) }
186 end
186 end
187 end
187 end
188 end
188 end
189
189
190 # Bulk edit a set of issues
190 # Bulk edit a set of issues
191 def bulk_edit
191 def bulk_edit
192 @issues.sort!
192 @issues.sort!
193 @available_statuses = @projects.map{|p|Workflow.available_statuses(p)}.inject{|memo,w|memo & w}
193 @available_statuses = @projects.map{|p|Workflow.available_statuses(p)}.inject{|memo,w|memo & w}
194 @custom_fields = @projects.map{|p|p.all_issue_custom_fields}.inject{|memo,c|memo & c}
194 @custom_fields = @projects.map{|p|p.all_issue_custom_fields}.inject{|memo,c|memo & c}
195 @assignables = @projects.map(&:assignable_users).inject{|memo,a| memo & a}
195 @assignables = @projects.map(&:assignable_users).inject{|memo,a| memo & a}
196 @trackers = @projects.map(&:trackers).inject{|memo,t| memo & t}
196 @trackers = @projects.map(&:trackers).inject{|memo,t| memo & t}
197 end
197 end
198
198
199 def bulk_update
199 def bulk_update
200 @issues.sort!
200 @issues.sort!
201 attributes = parse_params_for_bulk_issue_attributes(params)
201 attributes = parse_params_for_bulk_issue_attributes(params)
202
202
203 unsaved_issue_ids = []
203 unsaved_issue_ids = []
204 @issues.each do |issue|
204 @issues.each do |issue|
205 issue.reload
205 issue.reload
206 journal = issue.init_journal(User.current, params[:notes])
206 journal = issue.init_journal(User.current, params[:notes])
207 issue.safe_attributes = attributes
207 issue.safe_attributes = attributes
208 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
208 call_hook(:controller_issues_bulk_edit_before_save, { :params => params, :issue => issue })
209 unless issue.save
209 unless issue.save
210 # Keep unsaved issue ids to display them in flash error
210 # Keep unsaved issue ids to display them in flash error
211 unsaved_issue_ids << issue.id
211 unsaved_issue_ids << issue.id
212 end
212 end
213 end
213 end
214 set_flash_from_bulk_issue_save(@issues, unsaved_issue_ids)
214 set_flash_from_bulk_issue_save(@issues, unsaved_issue_ids)
215 redirect_back_or_default({:controller => 'issues', :action => 'index', :project_id => @project})
215 redirect_back_or_default({:controller => 'issues', :action => 'index', :project_id => @project})
216 end
216 end
217
217
218 def destroy
218 def destroy
219 @hours = TimeEntry.sum(:hours, :conditions => ['issue_id IN (?)', @issues]).to_f
219 @hours = TimeEntry.sum(:hours, :conditions => ['issue_id IN (?)', @issues]).to_f
220 if @hours > 0
220 if @hours > 0
221 case params[:todo]
221 case params[:todo]
222 when 'destroy'
222 when 'destroy'
223 # nothing to do
223 # nothing to do
224 when 'nullify'
224 when 'nullify'
225 TimeEntry.update_all('issue_id = NULL', ['issue_id IN (?)', @issues])
225 TimeEntry.update_all('issue_id = NULL', ['issue_id IN (?)', @issues])
226 when 'reassign'
226 when 'reassign'
227 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
227 reassign_to = @project.issues.find_by_id(params[:reassign_to_id])
228 if reassign_to.nil?
228 if reassign_to.nil?
229 flash.now[:error] = l(:error_issue_not_found_in_project)
229 flash.now[:error] = l(:error_issue_not_found_in_project)
230 return
230 return
231 else
231 else
232 TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
232 TimeEntry.update_all("issue_id = #{reassign_to.id}", ['issue_id IN (?)', @issues])
233 end
233 end
234 else
234 else
235 # display the destroy form if it's a user request
235 # display the destroy form if it's a user request
236 return unless api_request?
236 return unless api_request?
237 end
237 end
238 end
238 end
239 @issues.each do |issue|
239 @issues.each do |issue|
240 begin
240 begin
241 issue.reload.destroy
241 issue.reload.destroy
242 rescue ::ActiveRecord::RecordNotFound # raised by #reload if issue no longer exists
242 rescue ::ActiveRecord::RecordNotFound # raised by #reload if issue no longer exists
243 # nothing to do, issue was already deleted (eg. by a parent)
243 # nothing to do, issue was already deleted (eg. by a parent)
244 end
244 end
245 end
245 end
246 respond_to do |format|
246 respond_to do |format|
247 format.html { redirect_back_or_default(:action => 'index', :project_id => @project) }
247 format.html { redirect_back_or_default(:action => 'index', :project_id => @project) }
248 format.api { head :ok }
248 format.api { head :ok }
249 end
249 end
250 end
250 end
251
251
252 private
252 private
253 def find_issue
253 def find_issue
254 # Issue.visible.find(...) can not be used to redirect user to the login form
255 # if the issue actually exists but requires authentication
254 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
256 @issue = Issue.find(params[:id], :include => [:project, :tracker, :status, :author, :priority, :category])
257 unless @issue.visible?
258 deny_access
259 return
260 end
255 @project = @issue.project
261 @project = @issue.project
256 rescue ActiveRecord::RecordNotFound
262 rescue ActiveRecord::RecordNotFound
257 render_404
263 render_404
258 end
264 end
259
265
260 def find_project
266 def find_project
261 project_id = (params[:issue] && params[:issue][:project_id]) || params[:project_id]
267 project_id = (params[:issue] && params[:issue][:project_id]) || params[:project_id]
262 @project = Project.find(project_id)
268 @project = Project.find(project_id)
263 rescue ActiveRecord::RecordNotFound
269 rescue ActiveRecord::RecordNotFound
264 render_404
270 render_404
265 end
271 end
266
272
267 # Used by #edit and #update to set some common instance variables
273 # Used by #edit and #update to set some common instance variables
268 # from the params
274 # from the params
269 # TODO: Refactor, not everything in here is needed by #edit
275 # TODO: Refactor, not everything in here is needed by #edit
270 def update_issue_from_params
276 def update_issue_from_params
271 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
277 @allowed_statuses = @issue.new_statuses_allowed_to(User.current)
272 @priorities = IssuePriority.all
278 @priorities = IssuePriority.all
273 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
279 @edit_allowed = User.current.allowed_to?(:edit_issues, @project)
274 @time_entry = TimeEntry.new(:issue => @issue, :project => @issue.project)
280 @time_entry = TimeEntry.new(:issue => @issue, :project => @issue.project)
275 @time_entry.attributes = params[:time_entry]
281 @time_entry.attributes = params[:time_entry]
276
282
277 @notes = params[:notes] || (params[:issue].present? ? params[:issue][:notes] : nil)
283 @notes = params[:notes] || (params[:issue].present? ? params[:issue][:notes] : nil)
278 @issue.init_journal(User.current, @notes)
284 @issue.init_journal(User.current, @notes)
279 @issue.safe_attributes = params[:issue]
285 @issue.safe_attributes = params[:issue]
280 end
286 end
281
287
282 # TODO: Refactor, lots of extra code in here
288 # TODO: Refactor, lots of extra code in here
283 # TODO: Changing tracker on an existing issue should not trigger this
289 # TODO: Changing tracker on an existing issue should not trigger this
284 def build_new_issue_from_params
290 def build_new_issue_from_params
285 if params[:id].blank?
291 if params[:id].blank?
286 @issue = Issue.new
292 @issue = Issue.new
287 @issue.copy_from(params[:copy_from]) if params[:copy_from]
293 @issue.copy_from(params[:copy_from]) if params[:copy_from]
288 @issue.project = @project
294 @issue.project = @project
289 else
295 else
290 @issue = @project.issues.visible.find(params[:id])
296 @issue = @project.issues.visible.find(params[:id])
291 end
297 end
292
298
293 @issue.project = @project
299 @issue.project = @project
294 # Tracker must be set before custom field values
300 # Tracker must be set before custom field values
295 @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
301 @issue.tracker ||= @project.trackers.find((params[:issue] && params[:issue][:tracker_id]) || params[:tracker_id] || :first)
296 if @issue.tracker.nil?
302 if @issue.tracker.nil?
297 render_error l(:error_no_tracker_in_project)
303 render_error l(:error_no_tracker_in_project)
298 return false
304 return false
299 end
305 end
300 @issue.start_date ||= Date.today
306 @issue.start_date ||= Date.today
301 if params[:issue].is_a?(Hash)
307 if params[:issue].is_a?(Hash)
302 @issue.safe_attributes = params[:issue]
308 @issue.safe_attributes = params[:issue]
303 if User.current.allowed_to?(:add_issue_watchers, @project) && @issue.new_record?
309 if User.current.allowed_to?(:add_issue_watchers, @project) && @issue.new_record?
304 @issue.watcher_user_ids = params[:issue]['watcher_user_ids']
310 @issue.watcher_user_ids = params[:issue]['watcher_user_ids']
305 end
311 end
306 end
312 end
307 @issue.author = User.current
313 @issue.author = User.current
308 @priorities = IssuePriority.all
314 @priorities = IssuePriority.all
309 @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)
315 @allowed_statuses = @issue.new_statuses_allowed_to(User.current, true)
310 end
316 end
311
317
312 def check_for_default_issue_status
318 def check_for_default_issue_status
313 if IssueStatus.default.nil?
319 if IssueStatus.default.nil?
314 render_error l(:error_no_default_issue_status)
320 render_error l(:error_no_default_issue_status)
315 return false
321 return false
316 end
322 end
317 end
323 end
318
324
319 def parse_params_for_bulk_issue_attributes(params)
325 def parse_params_for_bulk_issue_attributes(params)
320 attributes = (params[:issue] || {}).reject {|k,v| v.blank?}
326 attributes = (params[:issue] || {}).reject {|k,v| v.blank?}
321 attributes.keys.each {|k| attributes[k] = '' if attributes[k] == 'none'}
327 attributes.keys.each {|k| attributes[k] = '' if attributes[k] == 'none'}
322 attributes[:custom_field_values].reject! {|k,v| v.blank?} if attributes[:custom_field_values]
328 attributes[:custom_field_values].reject! {|k,v| v.blank?} if attributes[:custom_field_values]
323 attributes
329 attributes
324 end
330 end
325 end
331 end
@@ -1,884 +1,902
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20
20
21 belongs_to :project
21 belongs_to :project
22 belongs_to :tracker
22 belongs_to :tracker
23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
23 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
24 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
25 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
25 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
26 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
27 belongs_to :priority, :class_name => 'IssuePriority', :foreign_key => 'priority_id'
28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
28 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
29
29
30 has_many :journals, :as => :journalized, :dependent => :destroy
30 has_many :journals, :as => :journalized, :dependent => :destroy
31 has_many :time_entries, :dependent => :delete_all
31 has_many :time_entries, :dependent => :delete_all
32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
32 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
33
33
34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
34 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
35 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
36
36
37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
37 acts_as_nested_set :scope => 'root_id', :dependent => :destroy
38 acts_as_attachable :after_remove => :attachment_removed
38 acts_as_attachable :after_remove => :attachment_removed
39 acts_as_customizable
39 acts_as_customizable
40 acts_as_watchable
40 acts_as_watchable
41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
41 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
42 :include => [:project, :journals],
42 :include => [:project, :journals],
43 # sort by id so that limited eager loading doesn't break with postgresql
43 # sort by id so that limited eager loading doesn't break with postgresql
44 :order_column => "#{table_name}.id"
44 :order_column => "#{table_name}.id"
45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
45 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id} (#{o.status}): #{o.subject}"},
46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
46 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}},
47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
47 :type => Proc.new {|o| 'issue' + (o.closed? ? ' closed' : '') }
48
48
49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
49 acts_as_activity_provider :find_options => {:include => [:project, :author, :tracker]},
50 :author_key => :author_id
50 :author_key => :author_id
51
51
52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
52 DONE_RATIO_OPTIONS = %w(issue_field issue_status)
53
53
54 attr_reader :current_journal
54 attr_reader :current_journal
55
55
56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
56 validates_presence_of :subject, :priority, :project, :tracker, :author, :status
57
57
58 validates_length_of :subject, :maximum => 255
58 validates_length_of :subject, :maximum => 255
59 validates_inclusion_of :done_ratio, :in => 0..100
59 validates_inclusion_of :done_ratio, :in => 0..100
60 validates_numericality_of :estimated_hours, :allow_nil => true
60 validates_numericality_of :estimated_hours, :allow_nil => true
61
61
62 named_scope :visible, lambda {|*args| { :include => :project,
62 named_scope :visible, lambda {|*args| { :include => :project,
63 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
63 :conditions => Issue.visible_condition(args.shift || User.current, *args) } }
64
64
65 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
65 named_scope :open, :conditions => ["#{IssueStatus.table_name}.is_closed = ?", false], :include => :status
66
66
67 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
67 named_scope :recently_updated, :order => "#{Issue.table_name}.updated_on DESC"
68 named_scope :with_limit, lambda { |limit| { :limit => limit} }
68 named_scope :with_limit, lambda { |limit| { :limit => limit} }
69 named_scope :on_active_project, :include => [:status, :project, :tracker],
69 named_scope :on_active_project, :include => [:status, :project, :tracker],
70 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
70 :conditions => ["#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"]
71
71
72 named_scope :without_version, lambda {
72 named_scope :without_version, lambda {
73 {
73 {
74 :conditions => { :fixed_version_id => nil}
74 :conditions => { :fixed_version_id => nil}
75 }
75 }
76 }
76 }
77
77
78 named_scope :with_query, lambda {|query|
78 named_scope :with_query, lambda {|query|
79 {
79 {
80 :conditions => Query.merge_conditions(query.statement)
80 :conditions => Query.merge_conditions(query.statement)
81 }
81 }
82 }
82 }
83
83
84 before_create :default_assign
84 before_create :default_assign
85 before_save :close_duplicates, :update_done_ratio_from_issue_status
85 before_save :close_duplicates, :update_done_ratio_from_issue_status
86 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
86 after_save :reschedule_following_issues, :update_nested_set_attributes, :update_parent_attributes, :create_journal
87 after_destroy :update_parent_attributes
87 after_destroy :update_parent_attributes
88
88
89 # Returns a SQL conditions string used to find all issues visible by the specified user
89 # Returns a SQL conditions string used to find all issues visible by the specified user
90 def self.visible_condition(user, options={})
90 def self.visible_condition(user, options={})
91 Project.allowed_to_condition(user, :view_issues, options)
91 Project.allowed_to_condition(user, :view_issues, options) do |role, user|
92 case role.issues_visibility
93 when 'default'
94 nil
95 when 'own'
96 "(#{table_name}.author_id = #{user.id} OR #{table_name}.assigned_to_id = #{user.id})"
97 else
98 '1=0'
99 end
100 end
92 end
101 end
93
102
94 # Returns true if usr or current user is allowed to view the issue
103 # Returns true if usr or current user is allowed to view the issue
95 def visible?(usr=nil)
104 def visible?(usr=nil)
96 (usr || User.current).allowed_to?(:view_issues, self.project)
105 (usr || User.current).allowed_to?(:view_issues, self.project) do |role, user|
106 case role.issues_visibility
107 when 'default'
108 true
109 when 'own'
110 self.author == user || self.assigned_to == user
111 else
112 false
113 end
114 end
97 end
115 end
98
116
99 def after_initialize
117 def after_initialize
100 if new_record?
118 if new_record?
101 # set default values for new records only
119 # set default values for new records only
102 self.status ||= IssueStatus.default
120 self.status ||= IssueStatus.default
103 self.priority ||= IssuePriority.default
121 self.priority ||= IssuePriority.default
104 end
122 end
105 end
123 end
106
124
107 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
125 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
108 def available_custom_fields
126 def available_custom_fields
109 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
127 (project && tracker) ? (project.all_issue_custom_fields & tracker.custom_fields.all) : []
110 end
128 end
111
129
112 def copy_from(arg)
130 def copy_from(arg)
113 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
131 issue = arg.is_a?(Issue) ? arg : Issue.visible.find(arg)
114 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
132 self.attributes = issue.attributes.dup.except("id", "root_id", "parent_id", "lft", "rgt", "created_on", "updated_on")
115 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
133 self.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
116 self.status = issue.status
134 self.status = issue.status
117 self
135 self
118 end
136 end
119
137
120 # Moves/copies an issue to a new project and tracker
138 # Moves/copies an issue to a new project and tracker
121 # Returns the moved/copied issue on success, false on failure
139 # Returns the moved/copied issue on success, false on failure
122 def move_to_project(*args)
140 def move_to_project(*args)
123 ret = Issue.transaction do
141 ret = Issue.transaction do
124 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
142 move_to_project_without_transaction(*args) || raise(ActiveRecord::Rollback)
125 end || false
143 end || false
126 end
144 end
127
145
128 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
146 def move_to_project_without_transaction(new_project, new_tracker = nil, options = {})
129 options ||= {}
147 options ||= {}
130 issue = options[:copy] ? self.class.new.copy_from(self) : self
148 issue = options[:copy] ? self.class.new.copy_from(self) : self
131
149
132 if new_project && issue.project_id != new_project.id
150 if new_project && issue.project_id != new_project.id
133 # delete issue relations
151 # delete issue relations
134 unless Setting.cross_project_issue_relations?
152 unless Setting.cross_project_issue_relations?
135 issue.relations_from.clear
153 issue.relations_from.clear
136 issue.relations_to.clear
154 issue.relations_to.clear
137 end
155 end
138 # issue is moved to another project
156 # issue is moved to another project
139 # reassign to the category with same name if any
157 # reassign to the category with same name if any
140 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
158 new_category = issue.category.nil? ? nil : new_project.issue_categories.find_by_name(issue.category.name)
141 issue.category = new_category
159 issue.category = new_category
142 # Keep the fixed_version if it's still valid in the new_project
160 # Keep the fixed_version if it's still valid in the new_project
143 unless new_project.shared_versions.include?(issue.fixed_version)
161 unless new_project.shared_versions.include?(issue.fixed_version)
144 issue.fixed_version = nil
162 issue.fixed_version = nil
145 end
163 end
146 issue.project = new_project
164 issue.project = new_project
147 if issue.parent && issue.parent.project_id != issue.project_id
165 if issue.parent && issue.parent.project_id != issue.project_id
148 issue.parent_issue_id = nil
166 issue.parent_issue_id = nil
149 end
167 end
150 end
168 end
151 if new_tracker
169 if new_tracker
152 issue.tracker = new_tracker
170 issue.tracker = new_tracker
153 issue.reset_custom_values!
171 issue.reset_custom_values!
154 end
172 end
155 if options[:copy]
173 if options[:copy]
156 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
174 issue.custom_field_values = self.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
157 issue.status = if options[:attributes] && options[:attributes][:status_id]
175 issue.status = if options[:attributes] && options[:attributes][:status_id]
158 IssueStatus.find_by_id(options[:attributes][:status_id])
176 IssueStatus.find_by_id(options[:attributes][:status_id])
159 else
177 else
160 self.status
178 self.status
161 end
179 end
162 end
180 end
163 # Allow bulk setting of attributes on the issue
181 # Allow bulk setting of attributes on the issue
164 if options[:attributes]
182 if options[:attributes]
165 issue.attributes = options[:attributes]
183 issue.attributes = options[:attributes]
166 end
184 end
167 if issue.save
185 if issue.save
168 unless options[:copy]
186 unless options[:copy]
169 # Manually update project_id on related time entries
187 # Manually update project_id on related time entries
170 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
188 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
171
189
172 issue.children.each do |child|
190 issue.children.each do |child|
173 unless child.move_to_project_without_transaction(new_project)
191 unless child.move_to_project_without_transaction(new_project)
174 # Move failed and transaction was rollback'd
192 # Move failed and transaction was rollback'd
175 return false
193 return false
176 end
194 end
177 end
195 end
178 end
196 end
179 else
197 else
180 return false
198 return false
181 end
199 end
182 issue
200 issue
183 end
201 end
184
202
185 def status_id=(sid)
203 def status_id=(sid)
186 self.status = nil
204 self.status = nil
187 write_attribute(:status_id, sid)
205 write_attribute(:status_id, sid)
188 end
206 end
189
207
190 def priority_id=(pid)
208 def priority_id=(pid)
191 self.priority = nil
209 self.priority = nil
192 write_attribute(:priority_id, pid)
210 write_attribute(:priority_id, pid)
193 end
211 end
194
212
195 def tracker_id=(tid)
213 def tracker_id=(tid)
196 self.tracker = nil
214 self.tracker = nil
197 result = write_attribute(:tracker_id, tid)
215 result = write_attribute(:tracker_id, tid)
198 @custom_field_values = nil
216 @custom_field_values = nil
199 result
217 result
200 end
218 end
201
219
202 # Overrides attributes= so that tracker_id gets assigned first
220 # Overrides attributes= so that tracker_id gets assigned first
203 def attributes_with_tracker_first=(new_attributes, *args)
221 def attributes_with_tracker_first=(new_attributes, *args)
204 return if new_attributes.nil?
222 return if new_attributes.nil?
205 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
223 new_tracker_id = new_attributes['tracker_id'] || new_attributes[:tracker_id]
206 if new_tracker_id
224 if new_tracker_id
207 self.tracker_id = new_tracker_id
225 self.tracker_id = new_tracker_id
208 end
226 end
209 send :attributes_without_tracker_first=, new_attributes, *args
227 send :attributes_without_tracker_first=, new_attributes, *args
210 end
228 end
211 # Do not redefine alias chain on reload (see #4838)
229 # Do not redefine alias chain on reload (see #4838)
212 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
230 alias_method_chain(:attributes=, :tracker_first) unless method_defined?(:attributes_without_tracker_first=)
213
231
214 def estimated_hours=(h)
232 def estimated_hours=(h)
215 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
233 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
216 end
234 end
217
235
218 safe_attributes 'tracker_id',
236 safe_attributes 'tracker_id',
219 'status_id',
237 'status_id',
220 'parent_issue_id',
238 'parent_issue_id',
221 'category_id',
239 'category_id',
222 'assigned_to_id',
240 'assigned_to_id',
223 'priority_id',
241 'priority_id',
224 'fixed_version_id',
242 'fixed_version_id',
225 'subject',
243 'subject',
226 'description',
244 'description',
227 'start_date',
245 'start_date',
228 'due_date',
246 'due_date',
229 'done_ratio',
247 'done_ratio',
230 'estimated_hours',
248 'estimated_hours',
231 'custom_field_values',
249 'custom_field_values',
232 'custom_fields',
250 'custom_fields',
233 'lock_version',
251 'lock_version',
234 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
252 :if => lambda {|issue, user| issue.new_record? || user.allowed_to?(:edit_issues, issue.project) }
235
253
236 safe_attributes 'status_id',
254 safe_attributes 'status_id',
237 'assigned_to_id',
255 'assigned_to_id',
238 'fixed_version_id',
256 'fixed_version_id',
239 'done_ratio',
257 'done_ratio',
240 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
258 :if => lambda {|issue, user| issue.new_statuses_allowed_to(user).any? }
241
259
242 # Safely sets attributes
260 # Safely sets attributes
243 # Should be called from controllers instead of #attributes=
261 # Should be called from controllers instead of #attributes=
244 # attr_accessible is too rough because we still want things like
262 # attr_accessible is too rough because we still want things like
245 # Issue.new(:project => foo) to work
263 # Issue.new(:project => foo) to work
246 # TODO: move workflow/permission checks from controllers to here
264 # TODO: move workflow/permission checks from controllers to here
247 def safe_attributes=(attrs, user=User.current)
265 def safe_attributes=(attrs, user=User.current)
248 return unless attrs.is_a?(Hash)
266 return unless attrs.is_a?(Hash)
249
267
250 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
268 # User can change issue attributes only if he has :edit permission or if a workflow transition is allowed
251 attrs = delete_unsafe_attributes(attrs, user)
269 attrs = delete_unsafe_attributes(attrs, user)
252 return if attrs.empty?
270 return if attrs.empty?
253
271
254 # Tracker must be set before since new_statuses_allowed_to depends on it.
272 # Tracker must be set before since new_statuses_allowed_to depends on it.
255 if t = attrs.delete('tracker_id')
273 if t = attrs.delete('tracker_id')
256 self.tracker_id = t
274 self.tracker_id = t
257 end
275 end
258
276
259 if attrs['status_id']
277 if attrs['status_id']
260 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
278 unless new_statuses_allowed_to(user).collect(&:id).include?(attrs['status_id'].to_i)
261 attrs.delete('status_id')
279 attrs.delete('status_id')
262 end
280 end
263 end
281 end
264
282
265 unless leaf?
283 unless leaf?
266 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
284 attrs.reject! {|k,v| %w(priority_id done_ratio start_date due_date estimated_hours).include?(k)}
267 end
285 end
268
286
269 if attrs.has_key?('parent_issue_id')
287 if attrs.has_key?('parent_issue_id')
270 if !user.allowed_to?(:manage_subtasks, project)
288 if !user.allowed_to?(:manage_subtasks, project)
271 attrs.delete('parent_issue_id')
289 attrs.delete('parent_issue_id')
272 elsif !attrs['parent_issue_id'].blank?
290 elsif !attrs['parent_issue_id'].blank?
273 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
291 attrs.delete('parent_issue_id') unless Issue.visible(user).exists?(attrs['parent_issue_id'].to_i)
274 end
292 end
275 end
293 end
276
294
277 self.attributes = attrs
295 self.attributes = attrs
278 end
296 end
279
297
280 def done_ratio
298 def done_ratio
281 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
299 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
282 status.default_done_ratio
300 status.default_done_ratio
283 else
301 else
284 read_attribute(:done_ratio)
302 read_attribute(:done_ratio)
285 end
303 end
286 end
304 end
287
305
288 def self.use_status_for_done_ratio?
306 def self.use_status_for_done_ratio?
289 Setting.issue_done_ratio == 'issue_status'
307 Setting.issue_done_ratio == 'issue_status'
290 end
308 end
291
309
292 def self.use_field_for_done_ratio?
310 def self.use_field_for_done_ratio?
293 Setting.issue_done_ratio == 'issue_field'
311 Setting.issue_done_ratio == 'issue_field'
294 end
312 end
295
313
296 def validate
314 def validate
297 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
315 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
298 errors.add :due_date, :not_a_date
316 errors.add :due_date, :not_a_date
299 end
317 end
300
318
301 if self.due_date and self.start_date and self.due_date < self.start_date
319 if self.due_date and self.start_date and self.due_date < self.start_date
302 errors.add :due_date, :greater_than_start_date
320 errors.add :due_date, :greater_than_start_date
303 end
321 end
304
322
305 if start_date && soonest_start && start_date < soonest_start
323 if start_date && soonest_start && start_date < soonest_start
306 errors.add :start_date, :invalid
324 errors.add :start_date, :invalid
307 end
325 end
308
326
309 if fixed_version
327 if fixed_version
310 if !assignable_versions.include?(fixed_version)
328 if !assignable_versions.include?(fixed_version)
311 errors.add :fixed_version_id, :inclusion
329 errors.add :fixed_version_id, :inclusion
312 elsif reopened? && fixed_version.closed?
330 elsif reopened? && fixed_version.closed?
313 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
331 errors.add_to_base I18n.t(:error_can_not_reopen_issue_on_closed_version)
314 end
332 end
315 end
333 end
316
334
317 # Checks that the issue can not be added/moved to a disabled tracker
335 # Checks that the issue can not be added/moved to a disabled tracker
318 if project && (tracker_id_changed? || project_id_changed?)
336 if project && (tracker_id_changed? || project_id_changed?)
319 unless project.trackers.include?(tracker)
337 unless project.trackers.include?(tracker)
320 errors.add :tracker_id, :inclusion
338 errors.add :tracker_id, :inclusion
321 end
339 end
322 end
340 end
323
341
324 # Checks parent issue assignment
342 # Checks parent issue assignment
325 if @parent_issue
343 if @parent_issue
326 if @parent_issue.project_id != project_id
344 if @parent_issue.project_id != project_id
327 errors.add :parent_issue_id, :not_same_project
345 errors.add :parent_issue_id, :not_same_project
328 elsif !new_record?
346 elsif !new_record?
329 # moving an existing issue
347 # moving an existing issue
330 if @parent_issue.root_id != root_id
348 if @parent_issue.root_id != root_id
331 # we can always move to another tree
349 # we can always move to another tree
332 elsif move_possible?(@parent_issue)
350 elsif move_possible?(@parent_issue)
333 # move accepted inside tree
351 # move accepted inside tree
334 else
352 else
335 errors.add :parent_issue_id, :not_a_valid_parent
353 errors.add :parent_issue_id, :not_a_valid_parent
336 end
354 end
337 end
355 end
338 end
356 end
339 end
357 end
340
358
341 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
359 # Set the done_ratio using the status if that setting is set. This will keep the done_ratios
342 # even if the user turns off the setting later
360 # even if the user turns off the setting later
343 def update_done_ratio_from_issue_status
361 def update_done_ratio_from_issue_status
344 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
362 if Issue.use_status_for_done_ratio? && status && status.default_done_ratio
345 self.done_ratio = status.default_done_ratio
363 self.done_ratio = status.default_done_ratio
346 end
364 end
347 end
365 end
348
366
349 def init_journal(user, notes = "")
367 def init_journal(user, notes = "")
350 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
368 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
351 @issue_before_change = self.clone
369 @issue_before_change = self.clone
352 @issue_before_change.status = self.status
370 @issue_before_change.status = self.status
353 @custom_values_before_change = {}
371 @custom_values_before_change = {}
354 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
372 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
355 # Make sure updated_on is updated when adding a note.
373 # Make sure updated_on is updated when adding a note.
356 updated_on_will_change!
374 updated_on_will_change!
357 @current_journal
375 @current_journal
358 end
376 end
359
377
360 # Return true if the issue is closed, otherwise false
378 # Return true if the issue is closed, otherwise false
361 def closed?
379 def closed?
362 self.status.is_closed?
380 self.status.is_closed?
363 end
381 end
364
382
365 # Return true if the issue is being reopened
383 # Return true if the issue is being reopened
366 def reopened?
384 def reopened?
367 if !new_record? && status_id_changed?
385 if !new_record? && status_id_changed?
368 status_was = IssueStatus.find_by_id(status_id_was)
386 status_was = IssueStatus.find_by_id(status_id_was)
369 status_new = IssueStatus.find_by_id(status_id)
387 status_new = IssueStatus.find_by_id(status_id)
370 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
388 if status_was && status_new && status_was.is_closed? && !status_new.is_closed?
371 return true
389 return true
372 end
390 end
373 end
391 end
374 false
392 false
375 end
393 end
376
394
377 # Return true if the issue is being closed
395 # Return true if the issue is being closed
378 def closing?
396 def closing?
379 if !new_record? && status_id_changed?
397 if !new_record? && status_id_changed?
380 status_was = IssueStatus.find_by_id(status_id_was)
398 status_was = IssueStatus.find_by_id(status_id_was)
381 status_new = IssueStatus.find_by_id(status_id)
399 status_new = IssueStatus.find_by_id(status_id)
382 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
400 if status_was && status_new && !status_was.is_closed? && status_new.is_closed?
383 return true
401 return true
384 end
402 end
385 end
403 end
386 false
404 false
387 end
405 end
388
406
389 # Returns true if the issue is overdue
407 # Returns true if the issue is overdue
390 def overdue?
408 def overdue?
391 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
409 !due_date.nil? && (due_date < Date.today) && !status.is_closed?
392 end
410 end
393
411
394 # Is the amount of work done less than it should for the due date
412 # Is the amount of work done less than it should for the due date
395 def behind_schedule?
413 def behind_schedule?
396 return false if start_date.nil? || due_date.nil?
414 return false if start_date.nil? || due_date.nil?
397 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
415 done_date = start_date + ((due_date - start_date+1)* done_ratio/100).floor
398 return done_date <= Date.today
416 return done_date <= Date.today
399 end
417 end
400
418
401 # Does this issue have children?
419 # Does this issue have children?
402 def children?
420 def children?
403 !leaf?
421 !leaf?
404 end
422 end
405
423
406 # Users the issue can be assigned to
424 # Users the issue can be assigned to
407 def assignable_users
425 def assignable_users
408 users = project.assignable_users
426 users = project.assignable_users
409 users << author if author
427 users << author if author
410 users.uniq.sort
428 users.uniq.sort
411 end
429 end
412
430
413 # Versions that the issue can be assigned to
431 # Versions that the issue can be assigned to
414 def assignable_versions
432 def assignable_versions
415 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
433 @assignable_versions ||= (project.shared_versions.open + [Version.find_by_id(fixed_version_id_was)]).compact.uniq.sort
416 end
434 end
417
435
418 # Returns true if this issue is blocked by another issue that is still open
436 # Returns true if this issue is blocked by another issue that is still open
419 def blocked?
437 def blocked?
420 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
438 !relations_to.detect {|ir| ir.relation_type == 'blocks' && !ir.issue_from.closed?}.nil?
421 end
439 end
422
440
423 # Returns an array of status that user is able to apply
441 # Returns an array of status that user is able to apply
424 def new_statuses_allowed_to(user, include_default=false)
442 def new_statuses_allowed_to(user, include_default=false)
425 statuses = status.find_new_statuses_allowed_to(
443 statuses = status.find_new_statuses_allowed_to(
426 user.roles_for_project(project),
444 user.roles_for_project(project),
427 tracker,
445 tracker,
428 author == user,
446 author == user,
429 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
447 assigned_to_id_changed? ? assigned_to_id_was == user.id : assigned_to_id == user.id
430 )
448 )
431 statuses << status unless statuses.empty?
449 statuses << status unless statuses.empty?
432 statuses << IssueStatus.default if include_default
450 statuses << IssueStatus.default if include_default
433 statuses = statuses.uniq.sort
451 statuses = statuses.uniq.sort
434 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
452 blocked? ? statuses.reject {|s| s.is_closed?} : statuses
435 end
453 end
436
454
437 # Returns the mail adresses of users that should be notified
455 # Returns the mail adresses of users that should be notified
438 def recipients
456 def recipients
439 notified = project.notified_users
457 notified = project.notified_users
440 # Author and assignee are always notified unless they have been
458 # Author and assignee are always notified unless they have been
441 # locked or don't want to be notified
459 # locked or don't want to be notified
442 notified << author if author && author.active? && author.notify_about?(self)
460 notified << author if author && author.active? && author.notify_about?(self)
443 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
461 notified << assigned_to if assigned_to && assigned_to.active? && assigned_to.notify_about?(self)
444 notified.uniq!
462 notified.uniq!
445 # Remove users that can not view the issue
463 # Remove users that can not view the issue
446 notified.reject! {|user| !visible?(user)}
464 notified.reject! {|user| !visible?(user)}
447 notified.collect(&:mail)
465 notified.collect(&:mail)
448 end
466 end
449
467
450 # Returns the total number of hours spent on this issue and its descendants
468 # Returns the total number of hours spent on this issue and its descendants
451 #
469 #
452 # Example:
470 # Example:
453 # spent_hours => 0.0
471 # spent_hours => 0.0
454 # spent_hours => 50.2
472 # spent_hours => 50.2
455 def spent_hours
473 def spent_hours
456 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
474 @spent_hours ||= self_and_descendants.sum("#{TimeEntry.table_name}.hours", :include => :time_entries).to_f || 0.0
457 end
475 end
458
476
459 def relations
477 def relations
460 (relations_from + relations_to).sort
478 (relations_from + relations_to).sort
461 end
479 end
462
480
463 def all_dependent_issues(except=[])
481 def all_dependent_issues(except=[])
464 except << self
482 except << self
465 dependencies = []
483 dependencies = []
466 relations_from.each do |relation|
484 relations_from.each do |relation|
467 if relation.issue_to && !except.include?(relation.issue_to)
485 if relation.issue_to && !except.include?(relation.issue_to)
468 dependencies << relation.issue_to
486 dependencies << relation.issue_to
469 dependencies += relation.issue_to.all_dependent_issues(except)
487 dependencies += relation.issue_to.all_dependent_issues(except)
470 end
488 end
471 end
489 end
472 dependencies
490 dependencies
473 end
491 end
474
492
475 # Returns an array of issues that duplicate this one
493 # Returns an array of issues that duplicate this one
476 def duplicates
494 def duplicates
477 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
495 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
478 end
496 end
479
497
480 # Returns the due date or the target due date if any
498 # Returns the due date or the target due date if any
481 # Used on gantt chart
499 # Used on gantt chart
482 def due_before
500 def due_before
483 due_date || (fixed_version ? fixed_version.effective_date : nil)
501 due_date || (fixed_version ? fixed_version.effective_date : nil)
484 end
502 end
485
503
486 # Returns the time scheduled for this issue.
504 # Returns the time scheduled for this issue.
487 #
505 #
488 # Example:
506 # Example:
489 # Start Date: 2/26/09, End Date: 3/04/09
507 # Start Date: 2/26/09, End Date: 3/04/09
490 # duration => 6
508 # duration => 6
491 def duration
509 def duration
492 (start_date && due_date) ? due_date - start_date : 0
510 (start_date && due_date) ? due_date - start_date : 0
493 end
511 end
494
512
495 def soonest_start
513 def soonest_start
496 @soonest_start ||= (
514 @soonest_start ||= (
497 relations_to.collect{|relation| relation.successor_soonest_start} +
515 relations_to.collect{|relation| relation.successor_soonest_start} +
498 ancestors.collect(&:soonest_start)
516 ancestors.collect(&:soonest_start)
499 ).compact.max
517 ).compact.max
500 end
518 end
501
519
502 def reschedule_after(date)
520 def reschedule_after(date)
503 return if date.nil?
521 return if date.nil?
504 if leaf?
522 if leaf?
505 if start_date.nil? || start_date < date
523 if start_date.nil? || start_date < date
506 self.start_date, self.due_date = date, date + duration
524 self.start_date, self.due_date = date, date + duration
507 save
525 save
508 end
526 end
509 else
527 else
510 leaves.each do |leaf|
528 leaves.each do |leaf|
511 leaf.reschedule_after(date)
529 leaf.reschedule_after(date)
512 end
530 end
513 end
531 end
514 end
532 end
515
533
516 def <=>(issue)
534 def <=>(issue)
517 if issue.nil?
535 if issue.nil?
518 -1
536 -1
519 elsif root_id != issue.root_id
537 elsif root_id != issue.root_id
520 (root_id || 0) <=> (issue.root_id || 0)
538 (root_id || 0) <=> (issue.root_id || 0)
521 else
539 else
522 (lft || 0) <=> (issue.lft || 0)
540 (lft || 0) <=> (issue.lft || 0)
523 end
541 end
524 end
542 end
525
543
526 def to_s
544 def to_s
527 "#{tracker} ##{id}: #{subject}"
545 "#{tracker} ##{id}: #{subject}"
528 end
546 end
529
547
530 # Returns a string of css classes that apply to the issue
548 # Returns a string of css classes that apply to the issue
531 def css_classes
549 def css_classes
532 s = "issue status-#{status.position} priority-#{priority.position}"
550 s = "issue status-#{status.position} priority-#{priority.position}"
533 s << ' closed' if closed?
551 s << ' closed' if closed?
534 s << ' overdue' if overdue?
552 s << ' overdue' if overdue?
535 s << ' child' if child?
553 s << ' child' if child?
536 s << ' parent' unless leaf?
554 s << ' parent' unless leaf?
537 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
555 s << ' created-by-me' if User.current.logged? && author_id == User.current.id
538 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
556 s << ' assigned-to-me' if User.current.logged? && assigned_to_id == User.current.id
539 s
557 s
540 end
558 end
541
559
542 # Saves an issue, time_entry, attachments, and a journal from the parameters
560 # Saves an issue, time_entry, attachments, and a journal from the parameters
543 # Returns false if save fails
561 # Returns false if save fails
544 def save_issue_with_child_records(params, existing_time_entry=nil)
562 def save_issue_with_child_records(params, existing_time_entry=nil)
545 Issue.transaction do
563 Issue.transaction do
546 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
564 if params[:time_entry] && (params[:time_entry][:hours].present? || params[:time_entry][:comments].present?) && User.current.allowed_to?(:log_time, project)
547 @time_entry = existing_time_entry || TimeEntry.new
565 @time_entry = existing_time_entry || TimeEntry.new
548 @time_entry.project = project
566 @time_entry.project = project
549 @time_entry.issue = self
567 @time_entry.issue = self
550 @time_entry.user = User.current
568 @time_entry.user = User.current
551 @time_entry.spent_on = Date.today
569 @time_entry.spent_on = Date.today
552 @time_entry.attributes = params[:time_entry]
570 @time_entry.attributes = params[:time_entry]
553 self.time_entries << @time_entry
571 self.time_entries << @time_entry
554 end
572 end
555
573
556 if valid?
574 if valid?
557 attachments = Attachment.attach_files(self, params[:attachments])
575 attachments = Attachment.attach_files(self, params[:attachments])
558
576
559 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
577 attachments[:files].each {|a| @current_journal.details << JournalDetail.new(:property => 'attachment', :prop_key => a.id, :value => a.filename)}
560 # TODO: Rename hook
578 # TODO: Rename hook
561 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
579 Redmine::Hook.call_hook(:controller_issues_edit_before_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
562 begin
580 begin
563 if save
581 if save
564 # TODO: Rename hook
582 # TODO: Rename hook
565 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
583 Redmine::Hook.call_hook(:controller_issues_edit_after_save, { :params => params, :issue => self, :time_entry => @time_entry, :journal => @current_journal})
566 else
584 else
567 raise ActiveRecord::Rollback
585 raise ActiveRecord::Rollback
568 end
586 end
569 rescue ActiveRecord::StaleObjectError
587 rescue ActiveRecord::StaleObjectError
570 attachments[:files].each(&:destroy)
588 attachments[:files].each(&:destroy)
571 errors.add_to_base l(:notice_locking_conflict)
589 errors.add_to_base l(:notice_locking_conflict)
572 raise ActiveRecord::Rollback
590 raise ActiveRecord::Rollback
573 end
591 end
574 end
592 end
575 end
593 end
576 end
594 end
577
595
578 # Unassigns issues from +version+ if it's no longer shared with issue's project
596 # Unassigns issues from +version+ if it's no longer shared with issue's project
579 def self.update_versions_from_sharing_change(version)
597 def self.update_versions_from_sharing_change(version)
580 # Update issues assigned to the version
598 # Update issues assigned to the version
581 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
599 update_versions(["#{Issue.table_name}.fixed_version_id = ?", version.id])
582 end
600 end
583
601
584 # Unassigns issues from versions that are no longer shared
602 # Unassigns issues from versions that are no longer shared
585 # after +project+ was moved
603 # after +project+ was moved
586 def self.update_versions_from_hierarchy_change(project)
604 def self.update_versions_from_hierarchy_change(project)
587 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
605 moved_project_ids = project.self_and_descendants.reload.collect(&:id)
588 # Update issues of the moved projects and issues assigned to a version of a moved project
606 # Update issues of the moved projects and issues assigned to a version of a moved project
589 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
607 Issue.update_versions(["#{Version.table_name}.project_id IN (?) OR #{Issue.table_name}.project_id IN (?)", moved_project_ids, moved_project_ids])
590 end
608 end
591
609
592 def parent_issue_id=(arg)
610 def parent_issue_id=(arg)
593 parent_issue_id = arg.blank? ? nil : arg.to_i
611 parent_issue_id = arg.blank? ? nil : arg.to_i
594 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
612 if parent_issue_id && @parent_issue = Issue.find_by_id(parent_issue_id)
595 @parent_issue.id
613 @parent_issue.id
596 else
614 else
597 @parent_issue = nil
615 @parent_issue = nil
598 nil
616 nil
599 end
617 end
600 end
618 end
601
619
602 def parent_issue_id
620 def parent_issue_id
603 if instance_variable_defined? :@parent_issue
621 if instance_variable_defined? :@parent_issue
604 @parent_issue.nil? ? nil : @parent_issue.id
622 @parent_issue.nil? ? nil : @parent_issue.id
605 else
623 else
606 parent_id
624 parent_id
607 end
625 end
608 end
626 end
609
627
610 # Extracted from the ReportsController.
628 # Extracted from the ReportsController.
611 def self.by_tracker(project)
629 def self.by_tracker(project)
612 count_and_group_by(:project => project,
630 count_and_group_by(:project => project,
613 :field => 'tracker_id',
631 :field => 'tracker_id',
614 :joins => Tracker.table_name)
632 :joins => Tracker.table_name)
615 end
633 end
616
634
617 def self.by_version(project)
635 def self.by_version(project)
618 count_and_group_by(:project => project,
636 count_and_group_by(:project => project,
619 :field => 'fixed_version_id',
637 :field => 'fixed_version_id',
620 :joins => Version.table_name)
638 :joins => Version.table_name)
621 end
639 end
622
640
623 def self.by_priority(project)
641 def self.by_priority(project)
624 count_and_group_by(:project => project,
642 count_and_group_by(:project => project,
625 :field => 'priority_id',
643 :field => 'priority_id',
626 :joins => IssuePriority.table_name)
644 :joins => IssuePriority.table_name)
627 end
645 end
628
646
629 def self.by_category(project)
647 def self.by_category(project)
630 count_and_group_by(:project => project,
648 count_and_group_by(:project => project,
631 :field => 'category_id',
649 :field => 'category_id',
632 :joins => IssueCategory.table_name)
650 :joins => IssueCategory.table_name)
633 end
651 end
634
652
635 def self.by_assigned_to(project)
653 def self.by_assigned_to(project)
636 count_and_group_by(:project => project,
654 count_and_group_by(:project => project,
637 :field => 'assigned_to_id',
655 :field => 'assigned_to_id',
638 :joins => User.table_name)
656 :joins => User.table_name)
639 end
657 end
640
658
641 def self.by_author(project)
659 def self.by_author(project)
642 count_and_group_by(:project => project,
660 count_and_group_by(:project => project,
643 :field => 'author_id',
661 :field => 'author_id',
644 :joins => User.table_name)
662 :joins => User.table_name)
645 end
663 end
646
664
647 def self.by_subproject(project)
665 def self.by_subproject(project)
648 ActiveRecord::Base.connection.select_all("select s.id as status_id,
666 ActiveRecord::Base.connection.select_all("select s.id as status_id,
649 s.is_closed as closed,
667 s.is_closed as closed,
650 #{Issue.table_name}.project_id as project_id,
668 #{Issue.table_name}.project_id as project_id,
651 count(#{Issue.table_name}.id) as total
669 count(#{Issue.table_name}.id) as total
652 from
670 from
653 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
671 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s
654 where
672 where
655 #{Issue.table_name}.status_id=s.id
673 #{Issue.table_name}.status_id=s.id
656 and #{Issue.table_name}.project_id = #{Project.table_name}.id
674 and #{Issue.table_name}.project_id = #{Project.table_name}.id
657 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
675 and #{visible_condition(User.current, :project => project, :with_subprojects => true)}
658 and #{Issue.table_name}.project_id <> #{project.id}
676 and #{Issue.table_name}.project_id <> #{project.id}
659 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
677 group by s.id, s.is_closed, #{Issue.table_name}.project_id") if project.descendants.active.any?
660 end
678 end
661 # End ReportsController extraction
679 # End ReportsController extraction
662
680
663 # Returns an array of projects that current user can move issues to
681 # Returns an array of projects that current user can move issues to
664 def self.allowed_target_projects_on_move
682 def self.allowed_target_projects_on_move
665 projects = []
683 projects = []
666 if User.current.admin?
684 if User.current.admin?
667 # admin is allowed to move issues to any active (visible) project
685 # admin is allowed to move issues to any active (visible) project
668 projects = Project.visible.all
686 projects = Project.visible.all
669 elsif User.current.logged?
687 elsif User.current.logged?
670 if Role.non_member.allowed_to?(:move_issues)
688 if Role.non_member.allowed_to?(:move_issues)
671 projects = Project.visible.all
689 projects = Project.visible.all
672 else
690 else
673 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
691 User.current.memberships.each {|m| projects << m.project if m.roles.detect {|r| r.allowed_to?(:move_issues)}}
674 end
692 end
675 end
693 end
676 projects
694 projects
677 end
695 end
678
696
679 private
697 private
680
698
681 def update_nested_set_attributes
699 def update_nested_set_attributes
682 if root_id.nil?
700 if root_id.nil?
683 # issue was just created
701 # issue was just created
684 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
702 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id)
685 set_default_left_and_right
703 set_default_left_and_right
686 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
704 Issue.update_all("root_id = #{root_id}, lft = #{lft}, rgt = #{rgt}", ["id = ?", id])
687 if @parent_issue
705 if @parent_issue
688 move_to_child_of(@parent_issue)
706 move_to_child_of(@parent_issue)
689 end
707 end
690 reload
708 reload
691 elsif parent_issue_id != parent_id
709 elsif parent_issue_id != parent_id
692 former_parent_id = parent_id
710 former_parent_id = parent_id
693 # moving an existing issue
711 # moving an existing issue
694 if @parent_issue && @parent_issue.root_id == root_id
712 if @parent_issue && @parent_issue.root_id == root_id
695 # inside the same tree
713 # inside the same tree
696 move_to_child_of(@parent_issue)
714 move_to_child_of(@parent_issue)
697 else
715 else
698 # to another tree
716 # to another tree
699 unless root?
717 unless root?
700 move_to_right_of(root)
718 move_to_right_of(root)
701 reload
719 reload
702 end
720 end
703 old_root_id = root_id
721 old_root_id = root_id
704 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
722 self.root_id = (@parent_issue.nil? ? id : @parent_issue.root_id )
705 target_maxright = nested_set_scope.maximum(right_column_name) || 0
723 target_maxright = nested_set_scope.maximum(right_column_name) || 0
706 offset = target_maxright + 1 - lft
724 offset = target_maxright + 1 - lft
707 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
725 Issue.update_all("root_id = #{root_id}, lft = lft + #{offset}, rgt = rgt + #{offset}",
708 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
726 ["root_id = ? AND lft >= ? AND rgt <= ? ", old_root_id, lft, rgt])
709 self[left_column_name] = lft + offset
727 self[left_column_name] = lft + offset
710 self[right_column_name] = rgt + offset
728 self[right_column_name] = rgt + offset
711 if @parent_issue
729 if @parent_issue
712 move_to_child_of(@parent_issue)
730 move_to_child_of(@parent_issue)
713 end
731 end
714 end
732 end
715 reload
733 reload
716 # delete invalid relations of all descendants
734 # delete invalid relations of all descendants
717 self_and_descendants.each do |issue|
735 self_and_descendants.each do |issue|
718 issue.relations.each do |relation|
736 issue.relations.each do |relation|
719 relation.destroy unless relation.valid?
737 relation.destroy unless relation.valid?
720 end
738 end
721 end
739 end
722 # update former parent
740 # update former parent
723 recalculate_attributes_for(former_parent_id) if former_parent_id
741 recalculate_attributes_for(former_parent_id) if former_parent_id
724 end
742 end
725 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
743 remove_instance_variable(:@parent_issue) if instance_variable_defined?(:@parent_issue)
726 end
744 end
727
745
728 def update_parent_attributes
746 def update_parent_attributes
729 recalculate_attributes_for(parent_id) if parent_id
747 recalculate_attributes_for(parent_id) if parent_id
730 end
748 end
731
749
732 def recalculate_attributes_for(issue_id)
750 def recalculate_attributes_for(issue_id)
733 if issue_id && p = Issue.find_by_id(issue_id)
751 if issue_id && p = Issue.find_by_id(issue_id)
734 # priority = highest priority of children
752 # priority = highest priority of children
735 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
753 if priority_position = p.children.maximum("#{IssuePriority.table_name}.position", :include => :priority)
736 p.priority = IssuePriority.find_by_position(priority_position)
754 p.priority = IssuePriority.find_by_position(priority_position)
737 end
755 end
738
756
739 # start/due dates = lowest/highest dates of children
757 # start/due dates = lowest/highest dates of children
740 p.start_date = p.children.minimum(:start_date)
758 p.start_date = p.children.minimum(:start_date)
741 p.due_date = p.children.maximum(:due_date)
759 p.due_date = p.children.maximum(:due_date)
742 if p.start_date && p.due_date && p.due_date < p.start_date
760 if p.start_date && p.due_date && p.due_date < p.start_date
743 p.start_date, p.due_date = p.due_date, p.start_date
761 p.start_date, p.due_date = p.due_date, p.start_date
744 end
762 end
745
763
746 # done ratio = weighted average ratio of leaves
764 # done ratio = weighted average ratio of leaves
747 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
765 unless Issue.use_status_for_done_ratio? && p.status && p.status.default_done_ratio
748 leaves_count = p.leaves.count
766 leaves_count = p.leaves.count
749 if leaves_count > 0
767 if leaves_count > 0
750 average = p.leaves.average(:estimated_hours).to_f
768 average = p.leaves.average(:estimated_hours).to_f
751 if average == 0
769 if average == 0
752 average = 1
770 average = 1
753 end
771 end
754 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
772 done = p.leaves.sum("COALESCE(estimated_hours, #{average}) * (CASE WHEN is_closed = #{connection.quoted_true} THEN 100 ELSE COALESCE(done_ratio, 0) END)", :include => :status).to_f
755 progress = done / (average * leaves_count)
773 progress = done / (average * leaves_count)
756 p.done_ratio = progress.round
774 p.done_ratio = progress.round
757 end
775 end
758 end
776 end
759
777
760 # estimate = sum of leaves estimates
778 # estimate = sum of leaves estimates
761 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
779 p.estimated_hours = p.leaves.sum(:estimated_hours).to_f
762 p.estimated_hours = nil if p.estimated_hours == 0.0
780 p.estimated_hours = nil if p.estimated_hours == 0.0
763
781
764 # ancestors will be recursively updated
782 # ancestors will be recursively updated
765 p.save(false)
783 p.save(false)
766 end
784 end
767 end
785 end
768
786
769 # Update issues so their versions are not pointing to a
787 # Update issues so their versions are not pointing to a
770 # fixed_version that is not shared with the issue's project
788 # fixed_version that is not shared with the issue's project
771 def self.update_versions(conditions=nil)
789 def self.update_versions(conditions=nil)
772 # Only need to update issues with a fixed_version from
790 # Only need to update issues with a fixed_version from
773 # a different project and that is not systemwide shared
791 # a different project and that is not systemwide shared
774 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
792 Issue.all(:conditions => merge_conditions("#{Issue.table_name}.fixed_version_id IS NOT NULL" +
775 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
793 " AND #{Issue.table_name}.project_id <> #{Version.table_name}.project_id" +
776 " AND #{Version.table_name}.sharing <> 'system'",
794 " AND #{Version.table_name}.sharing <> 'system'",
777 conditions),
795 conditions),
778 :include => [:project, :fixed_version]
796 :include => [:project, :fixed_version]
779 ).each do |issue|
797 ).each do |issue|
780 next if issue.project.nil? || issue.fixed_version.nil?
798 next if issue.project.nil? || issue.fixed_version.nil?
781 unless issue.project.shared_versions.include?(issue.fixed_version)
799 unless issue.project.shared_versions.include?(issue.fixed_version)
782 issue.init_journal(User.current)
800 issue.init_journal(User.current)
783 issue.fixed_version = nil
801 issue.fixed_version = nil
784 issue.save
802 issue.save
785 end
803 end
786 end
804 end
787 end
805 end
788
806
789 # Callback on attachment deletion
807 # Callback on attachment deletion
790 def attachment_removed(obj)
808 def attachment_removed(obj)
791 journal = init_journal(User.current)
809 journal = init_journal(User.current)
792 journal.details << JournalDetail.new(:property => 'attachment',
810 journal.details << JournalDetail.new(:property => 'attachment',
793 :prop_key => obj.id,
811 :prop_key => obj.id,
794 :old_value => obj.filename)
812 :old_value => obj.filename)
795 journal.save
813 journal.save
796 end
814 end
797
815
798 # Default assignment based on category
816 # Default assignment based on category
799 def default_assign
817 def default_assign
800 if assigned_to.nil? && category && category.assigned_to
818 if assigned_to.nil? && category && category.assigned_to
801 self.assigned_to = category.assigned_to
819 self.assigned_to = category.assigned_to
802 end
820 end
803 end
821 end
804
822
805 # Updates start/due dates of following issues
823 # Updates start/due dates of following issues
806 def reschedule_following_issues
824 def reschedule_following_issues
807 if start_date_changed? || due_date_changed?
825 if start_date_changed? || due_date_changed?
808 relations_from.each do |relation|
826 relations_from.each do |relation|
809 relation.set_issue_to_dates
827 relation.set_issue_to_dates
810 end
828 end
811 end
829 end
812 end
830 end
813
831
814 # Closes duplicates if the issue is being closed
832 # Closes duplicates if the issue is being closed
815 def close_duplicates
833 def close_duplicates
816 if closing?
834 if closing?
817 duplicates.each do |duplicate|
835 duplicates.each do |duplicate|
818 # Reload is need in case the duplicate was updated by a previous duplicate
836 # Reload is need in case the duplicate was updated by a previous duplicate
819 duplicate.reload
837 duplicate.reload
820 # Don't re-close it if it's already closed
838 # Don't re-close it if it's already closed
821 next if duplicate.closed?
839 next if duplicate.closed?
822 # Same user and notes
840 # Same user and notes
823 if @current_journal
841 if @current_journal
824 duplicate.init_journal(@current_journal.user, @current_journal.notes)
842 duplicate.init_journal(@current_journal.user, @current_journal.notes)
825 end
843 end
826 duplicate.update_attribute :status, self.status
844 duplicate.update_attribute :status, self.status
827 end
845 end
828 end
846 end
829 end
847 end
830
848
831 # Saves the changes in a Journal
849 # Saves the changes in a Journal
832 # Called after_save
850 # Called after_save
833 def create_journal
851 def create_journal
834 if @current_journal
852 if @current_journal
835 # attributes changes
853 # attributes changes
836 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
854 (Issue.column_names - %w(id root_id lft rgt lock_version created_on updated_on)).each {|c|
837 @current_journal.details << JournalDetail.new(:property => 'attr',
855 @current_journal.details << JournalDetail.new(:property => 'attr',
838 :prop_key => c,
856 :prop_key => c,
839 :old_value => @issue_before_change.send(c),
857 :old_value => @issue_before_change.send(c),
840 :value => send(c)) unless send(c)==@issue_before_change.send(c)
858 :value => send(c)) unless send(c)==@issue_before_change.send(c)
841 }
859 }
842 # custom fields changes
860 # custom fields changes
843 custom_values.each {|c|
861 custom_values.each {|c|
844 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
862 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
845 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
863 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
846 @current_journal.details << JournalDetail.new(:property => 'cf',
864 @current_journal.details << JournalDetail.new(:property => 'cf',
847 :prop_key => c.custom_field_id,
865 :prop_key => c.custom_field_id,
848 :old_value => @custom_values_before_change[c.custom_field_id],
866 :old_value => @custom_values_before_change[c.custom_field_id],
849 :value => c.value)
867 :value => c.value)
850 }
868 }
851 @current_journal.save
869 @current_journal.save
852 # reset current journal
870 # reset current journal
853 init_journal @current_journal.user, @current_journal.notes
871 init_journal @current_journal.user, @current_journal.notes
854 end
872 end
855 end
873 end
856
874
857 # Query generator for selecting groups of issue counts for a project
875 # Query generator for selecting groups of issue counts for a project
858 # based on specific criteria
876 # based on specific criteria
859 #
877 #
860 # Options
878 # Options
861 # * project - Project to search in.
879 # * project - Project to search in.
862 # * field - String. Issue field to key off of in the grouping.
880 # * field - String. Issue field to key off of in the grouping.
863 # * joins - String. The table name to join against.
881 # * joins - String. The table name to join against.
864 def self.count_and_group_by(options)
882 def self.count_and_group_by(options)
865 project = options.delete(:project)
883 project = options.delete(:project)
866 select_field = options.delete(:field)
884 select_field = options.delete(:field)
867 joins = options.delete(:joins)
885 joins = options.delete(:joins)
868
886
869 where = "#{Issue.table_name}.#{select_field}=j.id"
887 where = "#{Issue.table_name}.#{select_field}=j.id"
870
888
871 ActiveRecord::Base.connection.select_all("select s.id as status_id,
889 ActiveRecord::Base.connection.select_all("select s.id as status_id,
872 s.is_closed as closed,
890 s.is_closed as closed,
873 j.id as #{select_field},
891 j.id as #{select_field},
874 count(#{Issue.table_name}.id) as total
892 count(#{Issue.table_name}.id) as total
875 from
893 from
876 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
894 #{Issue.table_name}, #{Project.table_name}, #{IssueStatus.table_name} s, #{joins} j
877 where
895 where
878 #{Issue.table_name}.status_id=s.id
896 #{Issue.table_name}.status_id=s.id
879 and #{where}
897 and #{where}
880 and #{Issue.table_name}.project_id=#{Project.table_name}.id
898 and #{Issue.table_name}.project_id=#{Project.table_name}.id
881 and #{visible_condition(User.current, :project => project)}
899 and #{visible_condition(User.current, :project => project)}
882 group by s.id, s.is_closed, j.id")
900 group by s.id, s.is_closed, j.id")
883 end
901 end
884 end
902 end
@@ -1,851 +1,858
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Project < ActiveRecord::Base
18 class Project < ActiveRecord::Base
19 include Redmine::SafeAttributes
19 include Redmine::SafeAttributes
20
20
21 # Project statuses
21 # Project statuses
22 STATUS_ACTIVE = 1
22 STATUS_ACTIVE = 1
23 STATUS_ARCHIVED = 9
23 STATUS_ARCHIVED = 9
24
24
25 # Maximum length for project identifiers
25 # Maximum length for project identifiers
26 IDENTIFIER_MAX_LENGTH = 100
26 IDENTIFIER_MAX_LENGTH = 100
27
27
28 # Specific overidden Activities
28 # Specific overidden Activities
29 has_many :time_entry_activities
29 has_many :time_entry_activities
30 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
30 has_many :members, :include => [:user, :roles], :conditions => "#{User.table_name}.type='User' AND #{User.table_name}.status=#{User::STATUS_ACTIVE}"
31 has_many :memberships, :class_name => 'Member'
31 has_many :memberships, :class_name => 'Member'
32 has_many :member_principals, :class_name => 'Member',
32 has_many :member_principals, :class_name => 'Member',
33 :include => :principal,
33 :include => :principal,
34 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
34 :conditions => "#{Principal.table_name}.type='Group' OR (#{Principal.table_name}.type='User' AND #{Principal.table_name}.status=#{User::STATUS_ACTIVE})"
35 has_many :users, :through => :members
35 has_many :users, :through => :members
36 has_many :principals, :through => :member_principals, :source => :principal
36 has_many :principals, :through => :member_principals, :source => :principal
37
37
38 has_many :enabled_modules, :dependent => :delete_all
38 has_many :enabled_modules, :dependent => :delete_all
39 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
39 has_and_belongs_to_many :trackers, :order => "#{Tracker.table_name}.position"
40 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
40 has_many :issues, :dependent => :destroy, :order => "#{Issue.table_name}.created_on DESC", :include => [:status, :tracker]
41 has_many :issue_changes, :through => :issues, :source => :journals
41 has_many :issue_changes, :through => :issues, :source => :journals
42 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
42 has_many :versions, :dependent => :destroy, :order => "#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC"
43 has_many :time_entries, :dependent => :delete_all
43 has_many :time_entries, :dependent => :delete_all
44 has_many :queries, :dependent => :delete_all
44 has_many :queries, :dependent => :delete_all
45 has_many :documents, :dependent => :destroy
45 has_many :documents, :dependent => :destroy
46 has_many :news, :dependent => :destroy, :include => :author
46 has_many :news, :dependent => :destroy, :include => :author
47 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
47 has_many :issue_categories, :dependent => :delete_all, :order => "#{IssueCategory.table_name}.name"
48 has_many :boards, :dependent => :destroy, :order => "position ASC"
48 has_many :boards, :dependent => :destroy, :order => "position ASC"
49 has_one :repository, :dependent => :destroy
49 has_one :repository, :dependent => :destroy
50 has_many :changesets, :through => :repository
50 has_many :changesets, :through => :repository
51 has_one :wiki, :dependent => :destroy
51 has_one :wiki, :dependent => :destroy
52 # Custom field for the project issues
52 # Custom field for the project issues
53 has_and_belongs_to_many :issue_custom_fields,
53 has_and_belongs_to_many :issue_custom_fields,
54 :class_name => 'IssueCustomField',
54 :class_name => 'IssueCustomField',
55 :order => "#{CustomField.table_name}.position",
55 :order => "#{CustomField.table_name}.position",
56 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
56 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
57 :association_foreign_key => 'custom_field_id'
57 :association_foreign_key => 'custom_field_id'
58
58
59 acts_as_nested_set :order => 'name', :dependent => :destroy
59 acts_as_nested_set :order => 'name', :dependent => :destroy
60 acts_as_attachable :view_permission => :view_files,
60 acts_as_attachable :view_permission => :view_files,
61 :delete_permission => :manage_files
61 :delete_permission => :manage_files
62
62
63 acts_as_customizable
63 acts_as_customizable
64 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
64 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => 'id', :permission => nil
65 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
65 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
66 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
66 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
67 :author => nil
67 :author => nil
68
68
69 attr_protected :status
69 attr_protected :status
70
70
71 validates_presence_of :name, :identifier
71 validates_presence_of :name, :identifier
72 validates_uniqueness_of :identifier
72 validates_uniqueness_of :identifier
73 validates_associated :repository, :wiki
73 validates_associated :repository, :wiki
74 validates_length_of :name, :maximum => 255
74 validates_length_of :name, :maximum => 255
75 validates_length_of :homepage, :maximum => 255
75 validates_length_of :homepage, :maximum => 255
76 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
76 validates_length_of :identifier, :in => 1..IDENTIFIER_MAX_LENGTH
77 # donwcase letters, digits, dashes but not digits only
77 # donwcase letters, digits, dashes but not digits only
78 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :if => Proc.new { |p| p.identifier_changed? }
78 validates_format_of :identifier, :with => /^(?!\d+$)[a-z0-9\-]*$/, :if => Proc.new { |p| p.identifier_changed? }
79 # reserved words
79 # reserved words
80 validates_exclusion_of :identifier, :in => %w( new )
80 validates_exclusion_of :identifier, :in => %w( new )
81
81
82 before_destroy :delete_all_members
82 before_destroy :delete_all_members
83
83
84 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] } }
84 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] } }
85 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
85 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
86 named_scope :all_public, { :conditions => { :is_public => true } }
86 named_scope :all_public, { :conditions => { :is_public => true } }
87 named_scope :visible, lambda {|*args| {:conditions => Project.visible_condition(args.shift || User.current, *args) }}
87 named_scope :visible, lambda {|*args| {:conditions => Project.visible_condition(args.shift || User.current, *args) }}
88
88
89 def initialize(attributes = nil)
89 def initialize(attributes = nil)
90 super
90 super
91
91
92 initialized = (attributes || {}).stringify_keys
92 initialized = (attributes || {}).stringify_keys
93 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
93 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
94 self.identifier = Project.next_identifier
94 self.identifier = Project.next_identifier
95 end
95 end
96 if !initialized.key?('is_public')
96 if !initialized.key?('is_public')
97 self.is_public = Setting.default_projects_public?
97 self.is_public = Setting.default_projects_public?
98 end
98 end
99 if !initialized.key?('enabled_module_names')
99 if !initialized.key?('enabled_module_names')
100 self.enabled_module_names = Setting.default_projects_modules
100 self.enabled_module_names = Setting.default_projects_modules
101 end
101 end
102 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
102 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
103 self.trackers = Tracker.all
103 self.trackers = Tracker.all
104 end
104 end
105 end
105 end
106
106
107 def identifier=(identifier)
107 def identifier=(identifier)
108 super unless identifier_frozen?
108 super unless identifier_frozen?
109 end
109 end
110
110
111 def identifier_frozen?
111 def identifier_frozen?
112 errors[:identifier].nil? && !(new_record? || identifier.blank?)
112 errors[:identifier].nil? && !(new_record? || identifier.blank?)
113 end
113 end
114
114
115 # returns latest created projects
115 # returns latest created projects
116 # non public projects will be returned only if user is a member of those
116 # non public projects will be returned only if user is a member of those
117 def self.latest(user=nil, count=5)
117 def self.latest(user=nil, count=5)
118 visible(user).find(:all, :limit => count, :order => "created_on DESC")
118 visible(user).find(:all, :limit => count, :order => "created_on DESC")
119 end
119 end
120
120
121 def self.visible_by(user=nil)
121 def self.visible_by(user=nil)
122 ActiveSupport::Deprecation.warn "Project.visible_by is deprecated and will be removed in Redmine 1.3.0. Use Project.visible_condition instead."
122 ActiveSupport::Deprecation.warn "Project.visible_by is deprecated and will be removed in Redmine 1.3.0. Use Project.visible_condition instead."
123 visible_condition(user || User.current)
123 visible_condition(user || User.current)
124 end
124 end
125
125
126 # Returns a SQL conditions string used to find all projects visible by the specified user.
126 # Returns a SQL conditions string used to find all projects visible by the specified user.
127 #
127 #
128 # Examples:
128 # Examples:
129 # Project.visible_condition(admin) => "projects.status = 1"
129 # Project.visible_condition(admin) => "projects.status = 1"
130 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
130 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
131 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
131 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
132 def self.visible_condition(user, options={})
132 def self.visible_condition(user, options={})
133 allowed_to_condition(user, :view_project, options)
133 allowed_to_condition(user, :view_project, options)
134 end
134 end
135
135
136 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
136 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
137 #
137 #
138 # Valid options:
138 # Valid options:
139 # * :project => limit the condition to project
139 # * :project => limit the condition to project
140 # * :with_subprojects => limit the condition to project and its subprojects
140 # * :with_subprojects => limit the condition to project and its subprojects
141 # * :member => limit the condition to the user projects
141 # * :member => limit the condition to the user projects
142 def self.allowed_to_condition(user, permission, options={})
142 def self.allowed_to_condition(user, permission, options={})
143 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
143 base_statement = "#{Project.table_name}.status=#{Project::STATUS_ACTIVE}"
144 if perm = Redmine::AccessControl.permission(permission)
144 if perm = Redmine::AccessControl.permission(permission)
145 unless perm.project_module.nil?
145 unless perm.project_module.nil?
146 # If the permission belongs to a project module, make sure the module is enabled
146 # If the permission belongs to a project module, make sure the module is enabled
147 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
147 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
148 end
148 end
149 end
149 end
150 if options[:project]
150 if options[:project]
151 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
151 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
152 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
152 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
153 base_statement = "(#{project_statement}) AND (#{base_statement})"
153 base_statement = "(#{project_statement}) AND (#{base_statement})"
154 end
154 end
155
155
156 if user.admin?
156 if user.admin?
157 base_statement
157 base_statement
158 else
158 else
159 statement_by_role = {}
159 statement_by_role = {}
160 if user.logged?
160 if user.logged?
161 if Role.non_member.allowed_to?(permission) && !options[:member]
161 if Role.non_member.allowed_to?(permission) && !options[:member]
162 statement_by_role[Role.non_member] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
162 statement_by_role[Role.non_member] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
163 end
163 end
164 user.projects_by_role.each do |role, projects|
164 user.projects_by_role.each do |role, projects|
165 if role.allowed_to?(permission)
165 if role.allowed_to?(permission)
166 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
166 statement_by_role[role] = "#{Project.table_name}.id IN (#{projects.collect(&:id).join(',')})"
167 end
167 end
168 end
168 end
169 else
169 else
170 if Role.anonymous.allowed_to?(permission) && !options[:member]
170 if Role.anonymous.allowed_to?(permission) && !options[:member]
171 statement_by_role[Role.anonymous] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
171 statement_by_role[Role.anonymous] = "#{Project.table_name}.is_public = #{connection.quoted_true}"
172 end
172 end
173 end
173 end
174 if statement_by_role.empty?
174 if statement_by_role.empty?
175 "1=0"
175 "1=0"
176 else
176 else
177 if block_given?
178 statement_by_role.each do |role, statement|
179 if s = yield(role, user)
180 statement_by_role[role] = "(#{statement} AND (#{s}))"
181 end
182 end
183 end
177 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
184 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
178 end
185 end
179 end
186 end
180 end
187 end
181
188
182 # Returns the Systemwide and project specific activities
189 # Returns the Systemwide and project specific activities
183 def activities(include_inactive=false)
190 def activities(include_inactive=false)
184 if include_inactive
191 if include_inactive
185 return all_activities
192 return all_activities
186 else
193 else
187 return active_activities
194 return active_activities
188 end
195 end
189 end
196 end
190
197
191 # Will create a new Project specific Activity or update an existing one
198 # Will create a new Project specific Activity or update an existing one
192 #
199 #
193 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
200 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
194 # does not successfully save.
201 # does not successfully save.
195 def update_or_create_time_entry_activity(id, activity_hash)
202 def update_or_create_time_entry_activity(id, activity_hash)
196 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
203 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
197 self.create_time_entry_activity_if_needed(activity_hash)
204 self.create_time_entry_activity_if_needed(activity_hash)
198 else
205 else
199 activity = project.time_entry_activities.find_by_id(id.to_i)
206 activity = project.time_entry_activities.find_by_id(id.to_i)
200 activity.update_attributes(activity_hash) if activity
207 activity.update_attributes(activity_hash) if activity
201 end
208 end
202 end
209 end
203
210
204 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
211 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
205 #
212 #
206 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
213 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
207 # does not successfully save.
214 # does not successfully save.
208 def create_time_entry_activity_if_needed(activity)
215 def create_time_entry_activity_if_needed(activity)
209 if activity['parent_id']
216 if activity['parent_id']
210
217
211 parent_activity = TimeEntryActivity.find(activity['parent_id'])
218 parent_activity = TimeEntryActivity.find(activity['parent_id'])
212 activity['name'] = parent_activity.name
219 activity['name'] = parent_activity.name
213 activity['position'] = parent_activity.position
220 activity['position'] = parent_activity.position
214
221
215 if Enumeration.overridding_change?(activity, parent_activity)
222 if Enumeration.overridding_change?(activity, parent_activity)
216 project_activity = self.time_entry_activities.create(activity)
223 project_activity = self.time_entry_activities.create(activity)
217
224
218 if project_activity.new_record?
225 if project_activity.new_record?
219 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
226 raise ActiveRecord::Rollback, "Overridding TimeEntryActivity was not successfully saved"
220 else
227 else
221 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
228 self.time_entries.update_all("activity_id = #{project_activity.id}", ["activity_id = ?", parent_activity.id])
222 end
229 end
223 end
230 end
224 end
231 end
225 end
232 end
226
233
227 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
234 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
228 #
235 #
229 # Examples:
236 # Examples:
230 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
237 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
231 # project.project_condition(false) => "projects.id = 1"
238 # project.project_condition(false) => "projects.id = 1"
232 def project_condition(with_subprojects)
239 def project_condition(with_subprojects)
233 cond = "#{Project.table_name}.id = #{id}"
240 cond = "#{Project.table_name}.id = #{id}"
234 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
241 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
235 cond
242 cond
236 end
243 end
237
244
238 def self.find(*args)
245 def self.find(*args)
239 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
246 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
240 project = find_by_identifier(*args)
247 project = find_by_identifier(*args)
241 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
248 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
242 project
249 project
243 else
250 else
244 super
251 super
245 end
252 end
246 end
253 end
247
254
248 def to_param
255 def to_param
249 # id is used for projects with a numeric identifier (compatibility)
256 # id is used for projects with a numeric identifier (compatibility)
250 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
257 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id : identifier)
251 end
258 end
252
259
253 def active?
260 def active?
254 self.status == STATUS_ACTIVE
261 self.status == STATUS_ACTIVE
255 end
262 end
256
263
257 def archived?
264 def archived?
258 self.status == STATUS_ARCHIVED
265 self.status == STATUS_ARCHIVED
259 end
266 end
260
267
261 # Archives the project and its descendants
268 # Archives the project and its descendants
262 def archive
269 def archive
263 # Check that there is no issue of a non descendant project that is assigned
270 # Check that there is no issue of a non descendant project that is assigned
264 # to one of the project or descendant versions
271 # to one of the project or descendant versions
265 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
272 v_ids = self_and_descendants.collect {|p| p.version_ids}.flatten
266 if v_ids.any? && Issue.find(:first, :include => :project,
273 if v_ids.any? && Issue.find(:first, :include => :project,
267 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
274 :conditions => ["(#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?)" +
268 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
275 " AND #{Issue.table_name}.fixed_version_id IN (?)", lft, rgt, v_ids])
269 return false
276 return false
270 end
277 end
271 Project.transaction do
278 Project.transaction do
272 archive!
279 archive!
273 end
280 end
274 true
281 true
275 end
282 end
276
283
277 # Unarchives the project
284 # Unarchives the project
278 # All its ancestors must be active
285 # All its ancestors must be active
279 def unarchive
286 def unarchive
280 return false if ancestors.detect {|a| !a.active?}
287 return false if ancestors.detect {|a| !a.active?}
281 update_attribute :status, STATUS_ACTIVE
288 update_attribute :status, STATUS_ACTIVE
282 end
289 end
283
290
284 # Returns an array of projects the project can be moved to
291 # Returns an array of projects the project can be moved to
285 # by the current user
292 # by the current user
286 def allowed_parents
293 def allowed_parents
287 return @allowed_parents if @allowed_parents
294 return @allowed_parents if @allowed_parents
288 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
295 @allowed_parents = Project.find(:all, :conditions => Project.allowed_to_condition(User.current, :add_subprojects))
289 @allowed_parents = @allowed_parents - self_and_descendants
296 @allowed_parents = @allowed_parents - self_and_descendants
290 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
297 if User.current.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
291 @allowed_parents << nil
298 @allowed_parents << nil
292 end
299 end
293 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
300 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
294 @allowed_parents << parent
301 @allowed_parents << parent
295 end
302 end
296 @allowed_parents
303 @allowed_parents
297 end
304 end
298
305
299 # Sets the parent of the project with authorization check
306 # Sets the parent of the project with authorization check
300 def set_allowed_parent!(p)
307 def set_allowed_parent!(p)
301 unless p.nil? || p.is_a?(Project)
308 unless p.nil? || p.is_a?(Project)
302 if p.to_s.blank?
309 if p.to_s.blank?
303 p = nil
310 p = nil
304 else
311 else
305 p = Project.find_by_id(p)
312 p = Project.find_by_id(p)
306 return false unless p
313 return false unless p
307 end
314 end
308 end
315 end
309 if p.nil?
316 if p.nil?
310 if !new_record? && allowed_parents.empty?
317 if !new_record? && allowed_parents.empty?
311 return false
318 return false
312 end
319 end
313 elsif !allowed_parents.include?(p)
320 elsif !allowed_parents.include?(p)
314 return false
321 return false
315 end
322 end
316 set_parent!(p)
323 set_parent!(p)
317 end
324 end
318
325
319 # Sets the parent of the project
326 # Sets the parent of the project
320 # Argument can be either a Project, a String, a Fixnum or nil
327 # Argument can be either a Project, a String, a Fixnum or nil
321 def set_parent!(p)
328 def set_parent!(p)
322 unless p.nil? || p.is_a?(Project)
329 unless p.nil? || p.is_a?(Project)
323 if p.to_s.blank?
330 if p.to_s.blank?
324 p = nil
331 p = nil
325 else
332 else
326 p = Project.find_by_id(p)
333 p = Project.find_by_id(p)
327 return false unless p
334 return false unless p
328 end
335 end
329 end
336 end
330 if p == parent && !p.nil?
337 if p == parent && !p.nil?
331 # Nothing to do
338 # Nothing to do
332 true
339 true
333 elsif p.nil? || (p.active? && move_possible?(p))
340 elsif p.nil? || (p.active? && move_possible?(p))
334 # Insert the project so that target's children or root projects stay alphabetically sorted
341 # Insert the project so that target's children or root projects stay alphabetically sorted
335 sibs = (p.nil? ? self.class.roots : p.children)
342 sibs = (p.nil? ? self.class.roots : p.children)
336 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
343 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
337 if to_be_inserted_before
344 if to_be_inserted_before
338 move_to_left_of(to_be_inserted_before)
345 move_to_left_of(to_be_inserted_before)
339 elsif p.nil?
346 elsif p.nil?
340 if sibs.empty?
347 if sibs.empty?
341 # move_to_root adds the project in first (ie. left) position
348 # move_to_root adds the project in first (ie. left) position
342 move_to_root
349 move_to_root
343 else
350 else
344 move_to_right_of(sibs.last) unless self == sibs.last
351 move_to_right_of(sibs.last) unless self == sibs.last
345 end
352 end
346 else
353 else
347 # move_to_child_of adds the project in last (ie.right) position
354 # move_to_child_of adds the project in last (ie.right) position
348 move_to_child_of(p)
355 move_to_child_of(p)
349 end
356 end
350 Issue.update_versions_from_hierarchy_change(self)
357 Issue.update_versions_from_hierarchy_change(self)
351 true
358 true
352 else
359 else
353 # Can not move to the given target
360 # Can not move to the given target
354 false
361 false
355 end
362 end
356 end
363 end
357
364
358 # Returns an array of the trackers used by the project and its active sub projects
365 # Returns an array of the trackers used by the project and its active sub projects
359 def rolled_up_trackers
366 def rolled_up_trackers
360 @rolled_up_trackers ||=
367 @rolled_up_trackers ||=
361 Tracker.find(:all, :joins => :projects,
368 Tracker.find(:all, :joins => :projects,
362 :select => "DISTINCT #{Tracker.table_name}.*",
369 :select => "DISTINCT #{Tracker.table_name}.*",
363 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
370 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt],
364 :order => "#{Tracker.table_name}.position")
371 :order => "#{Tracker.table_name}.position")
365 end
372 end
366
373
367 # Closes open and locked project versions that are completed
374 # Closes open and locked project versions that are completed
368 def close_completed_versions
375 def close_completed_versions
369 Version.transaction do
376 Version.transaction do
370 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
377 versions.find(:all, :conditions => {:status => %w(open locked)}).each do |version|
371 if version.completed?
378 if version.completed?
372 version.update_attribute(:status, 'closed')
379 version.update_attribute(:status, 'closed')
373 end
380 end
374 end
381 end
375 end
382 end
376 end
383 end
377
384
378 # Returns a scope of the Versions on subprojects
385 # Returns a scope of the Versions on subprojects
379 def rolled_up_versions
386 def rolled_up_versions
380 @rolled_up_versions ||=
387 @rolled_up_versions ||=
381 Version.scoped(:include => :project,
388 Version.scoped(:include => :project,
382 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt])
389 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status = #{STATUS_ACTIVE}", lft, rgt])
383 end
390 end
384
391
385 # Returns a scope of the Versions used by the project
392 # Returns a scope of the Versions used by the project
386 def shared_versions
393 def shared_versions
387 @shared_versions ||= begin
394 @shared_versions ||= begin
388 r = root? ? self : root
395 r = root? ? self : root
389 Version.scoped(:include => :project,
396 Version.scoped(:include => :project,
390 :conditions => "#{Project.table_name}.id = #{id}" +
397 :conditions => "#{Project.table_name}.id = #{id}" +
391 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
398 " OR (#{Project.table_name}.status = #{Project::STATUS_ACTIVE} AND (" +
392 " #{Version.table_name}.sharing = 'system'" +
399 " #{Version.table_name}.sharing = 'system'" +
393 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
400 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
394 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
401 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
395 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
402 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
396 "))")
403 "))")
397 end
404 end
398 end
405 end
399
406
400 # Returns a hash of project users grouped by role
407 # Returns a hash of project users grouped by role
401 def users_by_role
408 def users_by_role
402 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
409 members.find(:all, :include => [:user, :roles]).inject({}) do |h, m|
403 m.roles.each do |r|
410 m.roles.each do |r|
404 h[r] ||= []
411 h[r] ||= []
405 h[r] << m.user
412 h[r] << m.user
406 end
413 end
407 h
414 h
408 end
415 end
409 end
416 end
410
417
411 # Deletes all project's members
418 # Deletes all project's members
412 def delete_all_members
419 def delete_all_members
413 me, mr = Member.table_name, MemberRole.table_name
420 me, mr = Member.table_name, MemberRole.table_name
414 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
421 connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
415 Member.delete_all(['project_id = ?', id])
422 Member.delete_all(['project_id = ?', id])
416 end
423 end
417
424
418 # Users issues can be assigned to
425 # Users issues can be assigned to
419 def assignable_users
426 def assignable_users
420 members.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.user}.sort
427 members.select {|m| m.roles.detect {|role| role.assignable?}}.collect {|m| m.user}.sort
421 end
428 end
422
429
423 # Returns the mail adresses of users that should be always notified on project events
430 # Returns the mail adresses of users that should be always notified on project events
424 def recipients
431 def recipients
425 notified_users.collect {|user| user.mail}
432 notified_users.collect {|user| user.mail}
426 end
433 end
427
434
428 # Returns the users that should be notified on project events
435 # Returns the users that should be notified on project events
429 def notified_users
436 def notified_users
430 # TODO: User part should be extracted to User#notify_about?
437 # TODO: User part should be extracted to User#notify_about?
431 members.select {|m| m.mail_notification? || m.user.mail_notification == 'all'}.collect {|m| m.user}
438 members.select {|m| m.mail_notification? || m.user.mail_notification == 'all'}.collect {|m| m.user}
432 end
439 end
433
440
434 # Returns an array of all custom fields enabled for project issues
441 # Returns an array of all custom fields enabled for project issues
435 # (explictly associated custom fields and custom fields enabled for all projects)
442 # (explictly associated custom fields and custom fields enabled for all projects)
436 def all_issue_custom_fields
443 def all_issue_custom_fields
437 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
444 @all_issue_custom_fields ||= (IssueCustomField.for_all + issue_custom_fields).uniq.sort
438 end
445 end
439
446
440 # Returns an array of all custom fields enabled for project time entries
447 # Returns an array of all custom fields enabled for project time entries
441 # (explictly associated custom fields and custom fields enabled for all projects)
448 # (explictly associated custom fields and custom fields enabled for all projects)
442 def all_time_entry_custom_fields
449 def all_time_entry_custom_fields
443 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
450 @all_time_entry_custom_fields ||= (TimeEntryCustomField.for_all + time_entry_custom_fields).uniq.sort
444 end
451 end
445
452
446 def project
453 def project
447 self
454 self
448 end
455 end
449
456
450 def <=>(project)
457 def <=>(project)
451 name.downcase <=> project.name.downcase
458 name.downcase <=> project.name.downcase
452 end
459 end
453
460
454 def to_s
461 def to_s
455 name
462 name
456 end
463 end
457
464
458 # Returns a short description of the projects (first lines)
465 # Returns a short description of the projects (first lines)
459 def short_description(length = 255)
466 def short_description(length = 255)
460 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
467 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
461 end
468 end
462
469
463 def css_classes
470 def css_classes
464 s = 'project'
471 s = 'project'
465 s << ' root' if root?
472 s << ' root' if root?
466 s << ' child' if child?
473 s << ' child' if child?
467 s << (leaf? ? ' leaf' : ' parent')
474 s << (leaf? ? ' leaf' : ' parent')
468 s
475 s
469 end
476 end
470
477
471 # The earliest start date of a project, based on it's issues and versions
478 # The earliest start date of a project, based on it's issues and versions
472 def start_date
479 def start_date
473 [
480 [
474 issues.minimum('start_date'),
481 issues.minimum('start_date'),
475 shared_versions.collect(&:effective_date),
482 shared_versions.collect(&:effective_date),
476 shared_versions.collect(&:start_date)
483 shared_versions.collect(&:start_date)
477 ].flatten.compact.min
484 ].flatten.compact.min
478 end
485 end
479
486
480 # The latest due date of an issue or version
487 # The latest due date of an issue or version
481 def due_date
488 def due_date
482 [
489 [
483 issues.maximum('due_date'),
490 issues.maximum('due_date'),
484 shared_versions.collect(&:effective_date),
491 shared_versions.collect(&:effective_date),
485 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
492 shared_versions.collect {|v| v.fixed_issues.maximum('due_date')}
486 ].flatten.compact.max
493 ].flatten.compact.max
487 end
494 end
488
495
489 def overdue?
496 def overdue?
490 active? && !due_date.nil? && (due_date < Date.today)
497 active? && !due_date.nil? && (due_date < Date.today)
491 end
498 end
492
499
493 # Returns the percent completed for this project, based on the
500 # Returns the percent completed for this project, based on the
494 # progress on it's versions.
501 # progress on it's versions.
495 def completed_percent(options={:include_subprojects => false})
502 def completed_percent(options={:include_subprojects => false})
496 if options.delete(:include_subprojects)
503 if options.delete(:include_subprojects)
497 total = self_and_descendants.collect(&:completed_percent).sum
504 total = self_and_descendants.collect(&:completed_percent).sum
498
505
499 total / self_and_descendants.count
506 total / self_and_descendants.count
500 else
507 else
501 if versions.count > 0
508 if versions.count > 0
502 total = versions.collect(&:completed_pourcent).sum
509 total = versions.collect(&:completed_pourcent).sum
503
510
504 total / versions.count
511 total / versions.count
505 else
512 else
506 100
513 100
507 end
514 end
508 end
515 end
509 end
516 end
510
517
511 # Return true if this project is allowed to do the specified action.
518 # Return true if this project is allowed to do the specified action.
512 # action can be:
519 # action can be:
513 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
520 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
514 # * a permission Symbol (eg. :edit_project)
521 # * a permission Symbol (eg. :edit_project)
515 def allows_to?(action)
522 def allows_to?(action)
516 if action.is_a? Hash
523 if action.is_a? Hash
517 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
524 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
518 else
525 else
519 allowed_permissions.include? action
526 allowed_permissions.include? action
520 end
527 end
521 end
528 end
522
529
523 def module_enabled?(module_name)
530 def module_enabled?(module_name)
524 module_name = module_name.to_s
531 module_name = module_name.to_s
525 enabled_modules.detect {|m| m.name == module_name}
532 enabled_modules.detect {|m| m.name == module_name}
526 end
533 end
527
534
528 def enabled_module_names=(module_names)
535 def enabled_module_names=(module_names)
529 if module_names && module_names.is_a?(Array)
536 if module_names && module_names.is_a?(Array)
530 module_names = module_names.collect(&:to_s).reject(&:blank?)
537 module_names = module_names.collect(&:to_s).reject(&:blank?)
531 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
538 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
532 else
539 else
533 enabled_modules.clear
540 enabled_modules.clear
534 end
541 end
535 end
542 end
536
543
537 # Returns an array of the enabled modules names
544 # Returns an array of the enabled modules names
538 def enabled_module_names
545 def enabled_module_names
539 enabled_modules.collect(&:name)
546 enabled_modules.collect(&:name)
540 end
547 end
541
548
542 safe_attributes 'name',
549 safe_attributes 'name',
543 'description',
550 'description',
544 'homepage',
551 'homepage',
545 'is_public',
552 'is_public',
546 'identifier',
553 'identifier',
547 'custom_field_values',
554 'custom_field_values',
548 'custom_fields',
555 'custom_fields',
549 'tracker_ids',
556 'tracker_ids',
550 'issue_custom_field_ids'
557 'issue_custom_field_ids'
551
558
552 safe_attributes 'enabled_module_names',
559 safe_attributes 'enabled_module_names',
553 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
560 :if => lambda {|project, user| project.new_record? || user.allowed_to?(:select_project_modules, project) }
554
561
555 # Returns an array of projects that are in this project's hierarchy
562 # Returns an array of projects that are in this project's hierarchy
556 #
563 #
557 # Example: parents, children, siblings
564 # Example: parents, children, siblings
558 def hierarchy
565 def hierarchy
559 parents = project.self_and_ancestors || []
566 parents = project.self_and_ancestors || []
560 descendants = project.descendants || []
567 descendants = project.descendants || []
561 project_hierarchy = parents | descendants # Set union
568 project_hierarchy = parents | descendants # Set union
562 end
569 end
563
570
564 # Returns an auto-generated project identifier based on the last identifier used
571 # Returns an auto-generated project identifier based on the last identifier used
565 def self.next_identifier
572 def self.next_identifier
566 p = Project.find(:first, :order => 'created_on DESC')
573 p = Project.find(:first, :order => 'created_on DESC')
567 p.nil? ? nil : p.identifier.to_s.succ
574 p.nil? ? nil : p.identifier.to_s.succ
568 end
575 end
569
576
570 # Copies and saves the Project instance based on the +project+.
577 # Copies and saves the Project instance based on the +project+.
571 # Duplicates the source project's:
578 # Duplicates the source project's:
572 # * Wiki
579 # * Wiki
573 # * Versions
580 # * Versions
574 # * Categories
581 # * Categories
575 # * Issues
582 # * Issues
576 # * Members
583 # * Members
577 # * Queries
584 # * Queries
578 #
585 #
579 # Accepts an +options+ argument to specify what to copy
586 # Accepts an +options+ argument to specify what to copy
580 #
587 #
581 # Examples:
588 # Examples:
582 # project.copy(1) # => copies everything
589 # project.copy(1) # => copies everything
583 # project.copy(1, :only => 'members') # => copies members only
590 # project.copy(1, :only => 'members') # => copies members only
584 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
591 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
585 def copy(project, options={})
592 def copy(project, options={})
586 project = project.is_a?(Project) ? project : Project.find(project)
593 project = project.is_a?(Project) ? project : Project.find(project)
587
594
588 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
595 to_be_copied = %w(wiki versions issue_categories issues members queries boards)
589 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
596 to_be_copied = to_be_copied & options[:only].to_a unless options[:only].nil?
590
597
591 Project.transaction do
598 Project.transaction do
592 if save
599 if save
593 reload
600 reload
594 to_be_copied.each do |name|
601 to_be_copied.each do |name|
595 send "copy_#{name}", project
602 send "copy_#{name}", project
596 end
603 end
597 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
604 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
598 save
605 save
599 end
606 end
600 end
607 end
601 end
608 end
602
609
603
610
604 # Copies +project+ and returns the new instance. This will not save
611 # Copies +project+ and returns the new instance. This will not save
605 # the copy
612 # the copy
606 def self.copy_from(project)
613 def self.copy_from(project)
607 begin
614 begin
608 project = project.is_a?(Project) ? project : Project.find(project)
615 project = project.is_a?(Project) ? project : Project.find(project)
609 if project
616 if project
610 # clear unique attributes
617 # clear unique attributes
611 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
618 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
612 copy = Project.new(attributes)
619 copy = Project.new(attributes)
613 copy.enabled_modules = project.enabled_modules
620 copy.enabled_modules = project.enabled_modules
614 copy.trackers = project.trackers
621 copy.trackers = project.trackers
615 copy.custom_values = project.custom_values.collect {|v| v.clone}
622 copy.custom_values = project.custom_values.collect {|v| v.clone}
616 copy.issue_custom_fields = project.issue_custom_fields
623 copy.issue_custom_fields = project.issue_custom_fields
617 return copy
624 return copy
618 else
625 else
619 return nil
626 return nil
620 end
627 end
621 rescue ActiveRecord::RecordNotFound
628 rescue ActiveRecord::RecordNotFound
622 return nil
629 return nil
623 end
630 end
624 end
631 end
625
632
626 # Yields the given block for each project with its level in the tree
633 # Yields the given block for each project with its level in the tree
627 def self.project_tree(projects, &block)
634 def self.project_tree(projects, &block)
628 ancestors = []
635 ancestors = []
629 projects.sort_by(&:lft).each do |project|
636 projects.sort_by(&:lft).each do |project|
630 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
637 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
631 ancestors.pop
638 ancestors.pop
632 end
639 end
633 yield project, ancestors.size
640 yield project, ancestors.size
634 ancestors << project
641 ancestors << project
635 end
642 end
636 end
643 end
637
644
638 private
645 private
639
646
640 # Copies wiki from +project+
647 # Copies wiki from +project+
641 def copy_wiki(project)
648 def copy_wiki(project)
642 # Check that the source project has a wiki first
649 # Check that the source project has a wiki first
643 unless project.wiki.nil?
650 unless project.wiki.nil?
644 self.wiki ||= Wiki.new
651 self.wiki ||= Wiki.new
645 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
652 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
646 wiki_pages_map = {}
653 wiki_pages_map = {}
647 project.wiki.pages.each do |page|
654 project.wiki.pages.each do |page|
648 # Skip pages without content
655 # Skip pages without content
649 next if page.content.nil?
656 next if page.content.nil?
650 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
657 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
651 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
658 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
652 new_wiki_page.content = new_wiki_content
659 new_wiki_page.content = new_wiki_content
653 wiki.pages << new_wiki_page
660 wiki.pages << new_wiki_page
654 wiki_pages_map[page.id] = new_wiki_page
661 wiki_pages_map[page.id] = new_wiki_page
655 end
662 end
656 wiki.save
663 wiki.save
657 # Reproduce page hierarchy
664 # Reproduce page hierarchy
658 project.wiki.pages.each do |page|
665 project.wiki.pages.each do |page|
659 if page.parent_id && wiki_pages_map[page.id]
666 if page.parent_id && wiki_pages_map[page.id]
660 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
667 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
661 wiki_pages_map[page.id].save
668 wiki_pages_map[page.id].save
662 end
669 end
663 end
670 end
664 end
671 end
665 end
672 end
666
673
667 # Copies versions from +project+
674 # Copies versions from +project+
668 def copy_versions(project)
675 def copy_versions(project)
669 project.versions.each do |version|
676 project.versions.each do |version|
670 new_version = Version.new
677 new_version = Version.new
671 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
678 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
672 self.versions << new_version
679 self.versions << new_version
673 end
680 end
674 end
681 end
675
682
676 # Copies issue categories from +project+
683 # Copies issue categories from +project+
677 def copy_issue_categories(project)
684 def copy_issue_categories(project)
678 project.issue_categories.each do |issue_category|
685 project.issue_categories.each do |issue_category|
679 new_issue_category = IssueCategory.new
686 new_issue_category = IssueCategory.new
680 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
687 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
681 self.issue_categories << new_issue_category
688 self.issue_categories << new_issue_category
682 end
689 end
683 end
690 end
684
691
685 # Copies issues from +project+
692 # Copies issues from +project+
686 # Note: issues assigned to a closed version won't be copied due to validation rules
693 # Note: issues assigned to a closed version won't be copied due to validation rules
687 def copy_issues(project)
694 def copy_issues(project)
688 # Stores the source issue id as a key and the copied issues as the
695 # Stores the source issue id as a key and the copied issues as the
689 # value. Used to map the two togeather for issue relations.
696 # value. Used to map the two togeather for issue relations.
690 issues_map = {}
697 issues_map = {}
691
698
692 # Get issues sorted by root_id, lft so that parent issues
699 # Get issues sorted by root_id, lft so that parent issues
693 # get copied before their children
700 # get copied before their children
694 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
701 project.issues.find(:all, :order => 'root_id, lft').each do |issue|
695 new_issue = Issue.new
702 new_issue = Issue.new
696 new_issue.copy_from(issue)
703 new_issue.copy_from(issue)
697 new_issue.project = self
704 new_issue.project = self
698 # Reassign fixed_versions by name, since names are unique per
705 # Reassign fixed_versions by name, since names are unique per
699 # project and the versions for self are not yet saved
706 # project and the versions for self are not yet saved
700 if issue.fixed_version
707 if issue.fixed_version
701 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
708 new_issue.fixed_version = self.versions.select {|v| v.name == issue.fixed_version.name}.first
702 end
709 end
703 # Reassign the category by name, since names are unique per
710 # Reassign the category by name, since names are unique per
704 # project and the categories for self are not yet saved
711 # project and the categories for self are not yet saved
705 if issue.category
712 if issue.category
706 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
713 new_issue.category = self.issue_categories.select {|c| c.name == issue.category.name}.first
707 end
714 end
708 # Parent issue
715 # Parent issue
709 if issue.parent_id
716 if issue.parent_id
710 if copied_parent = issues_map[issue.parent_id]
717 if copied_parent = issues_map[issue.parent_id]
711 new_issue.parent_issue_id = copied_parent.id
718 new_issue.parent_issue_id = copied_parent.id
712 end
719 end
713 end
720 end
714
721
715 self.issues << new_issue
722 self.issues << new_issue
716 if new_issue.new_record?
723 if new_issue.new_record?
717 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
724 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info
718 else
725 else
719 issues_map[issue.id] = new_issue unless new_issue.new_record?
726 issues_map[issue.id] = new_issue unless new_issue.new_record?
720 end
727 end
721 end
728 end
722
729
723 # Relations after in case issues related each other
730 # Relations after in case issues related each other
724 project.issues.each do |issue|
731 project.issues.each do |issue|
725 new_issue = issues_map[issue.id]
732 new_issue = issues_map[issue.id]
726 unless new_issue
733 unless new_issue
727 # Issue was not copied
734 # Issue was not copied
728 next
735 next
729 end
736 end
730
737
731 # Relations
738 # Relations
732 issue.relations_from.each do |source_relation|
739 issue.relations_from.each do |source_relation|
733 new_issue_relation = IssueRelation.new
740 new_issue_relation = IssueRelation.new
734 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
741 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
735 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
742 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
736 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
743 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
737 new_issue_relation.issue_to = source_relation.issue_to
744 new_issue_relation.issue_to = source_relation.issue_to
738 end
745 end
739 new_issue.relations_from << new_issue_relation
746 new_issue.relations_from << new_issue_relation
740 end
747 end
741
748
742 issue.relations_to.each do |source_relation|
749 issue.relations_to.each do |source_relation|
743 new_issue_relation = IssueRelation.new
750 new_issue_relation = IssueRelation.new
744 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
751 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
745 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
752 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
746 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
753 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
747 new_issue_relation.issue_from = source_relation.issue_from
754 new_issue_relation.issue_from = source_relation.issue_from
748 end
755 end
749 new_issue.relations_to << new_issue_relation
756 new_issue.relations_to << new_issue_relation
750 end
757 end
751 end
758 end
752 end
759 end
753
760
754 # Copies members from +project+
761 # Copies members from +project+
755 def copy_members(project)
762 def copy_members(project)
756 # Copy users first, then groups to handle members with inherited and given roles
763 # Copy users first, then groups to handle members with inherited and given roles
757 members_to_copy = []
764 members_to_copy = []
758 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
765 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
759 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
766 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
760
767
761 members_to_copy.each do |member|
768 members_to_copy.each do |member|
762 new_member = Member.new
769 new_member = Member.new
763 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
770 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
764 # only copy non inherited roles
771 # only copy non inherited roles
765 # inherited roles will be added when copying the group membership
772 # inherited roles will be added when copying the group membership
766 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
773 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
767 next if role_ids.empty?
774 next if role_ids.empty?
768 new_member.role_ids = role_ids
775 new_member.role_ids = role_ids
769 new_member.project = self
776 new_member.project = self
770 self.members << new_member
777 self.members << new_member
771 end
778 end
772 end
779 end
773
780
774 # Copies queries from +project+
781 # Copies queries from +project+
775 def copy_queries(project)
782 def copy_queries(project)
776 project.queries.each do |query|
783 project.queries.each do |query|
777 new_query = Query.new
784 new_query = Query.new
778 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
785 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria")
779 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
786 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
780 new_query.project = self
787 new_query.project = self
781 self.queries << new_query
788 self.queries << new_query
782 end
789 end
783 end
790 end
784
791
785 # Copies boards from +project+
792 # Copies boards from +project+
786 def copy_boards(project)
793 def copy_boards(project)
787 project.boards.each do |board|
794 project.boards.each do |board|
788 new_board = Board.new
795 new_board = Board.new
789 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
796 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
790 new_board.project = self
797 new_board.project = self
791 self.boards << new_board
798 self.boards << new_board
792 end
799 end
793 end
800 end
794
801
795 def allowed_permissions
802 def allowed_permissions
796 @allowed_permissions ||= begin
803 @allowed_permissions ||= begin
797 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
804 module_names = enabled_modules.all(:select => :name).collect {|m| m.name}
798 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
805 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
799 end
806 end
800 end
807 end
801
808
802 def allowed_actions
809 def allowed_actions
803 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
810 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
804 end
811 end
805
812
806 # Returns all the active Systemwide and project specific activities
813 # Returns all the active Systemwide and project specific activities
807 def active_activities
814 def active_activities
808 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
815 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
809
816
810 if overridden_activity_ids.empty?
817 if overridden_activity_ids.empty?
811 return TimeEntryActivity.shared.active
818 return TimeEntryActivity.shared.active
812 else
819 else
813 return system_activities_and_project_overrides
820 return system_activities_and_project_overrides
814 end
821 end
815 end
822 end
816
823
817 # Returns all the Systemwide and project specific activities
824 # Returns all the Systemwide and project specific activities
818 # (inactive and active)
825 # (inactive and active)
819 def all_activities
826 def all_activities
820 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
827 overridden_activity_ids = self.time_entry_activities.collect(&:parent_id)
821
828
822 if overridden_activity_ids.empty?
829 if overridden_activity_ids.empty?
823 return TimeEntryActivity.shared
830 return TimeEntryActivity.shared
824 else
831 else
825 return system_activities_and_project_overrides(true)
832 return system_activities_and_project_overrides(true)
826 end
833 end
827 end
834 end
828
835
829 # Returns the systemwide active activities merged with the project specific overrides
836 # Returns the systemwide active activities merged with the project specific overrides
830 def system_activities_and_project_overrides(include_inactive=false)
837 def system_activities_and_project_overrides(include_inactive=false)
831 if include_inactive
838 if include_inactive
832 return TimeEntryActivity.shared.
839 return TimeEntryActivity.shared.
833 find(:all,
840 find(:all,
834 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
841 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
835 self.time_entry_activities
842 self.time_entry_activities
836 else
843 else
837 return TimeEntryActivity.shared.active.
844 return TimeEntryActivity.shared.active.
838 find(:all,
845 find(:all,
839 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
846 :conditions => ["id NOT IN (?)", self.time_entry_activities.collect(&:parent_id)]) +
840 self.time_entry_activities.active
847 self.time_entry_activities.active
841 end
848 end
842 end
849 end
843
850
844 # Archives subprojects recursively
851 # Archives subprojects recursively
845 def archive!
852 def archive!
846 children.each do |subproject|
853 children.each do |subproject|
847 subproject.send :archive!
854 subproject.send :archive!
848 end
855 end
849 update_attribute :status, STATUS_ARCHIVED
856 update_attribute :status, STATUS_ARCHIVED
850 end
857 end
851 end
858 end
@@ -1,170 +1,178
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Role < ActiveRecord::Base
18 class Role < ActiveRecord::Base
19 # Built-in roles
19 # Built-in roles
20 BUILTIN_NON_MEMBER = 1
20 BUILTIN_NON_MEMBER = 1
21 BUILTIN_ANONYMOUS = 2
21 BUILTIN_ANONYMOUS = 2
22
23 ISSUES_VISIBILITY_OPTIONS = [
24 ['default', :label_issues_visibility_all],
25 ['own', :label_issues_visibility_own]
26 ]
22
27
23 named_scope :givable, { :conditions => "builtin = 0", :order => 'position' }
28 named_scope :givable, { :conditions => "builtin = 0", :order => 'position' }
24 named_scope :builtin, lambda { |*args|
29 named_scope :builtin, lambda { |*args|
25 compare = 'not' if args.first == true
30 compare = 'not' if args.first == true
26 { :conditions => "#{compare} builtin = 0" }
31 { :conditions => "#{compare} builtin = 0" }
27 }
32 }
28
33
29 before_destroy :check_deletable
34 before_destroy :check_deletable
30 has_many :workflows, :dependent => :delete_all do
35 has_many :workflows, :dependent => :delete_all do
31 def copy(source_role)
36 def copy(source_role)
32 Workflow.copy(nil, source_role, nil, proxy_owner)
37 Workflow.copy(nil, source_role, nil, proxy_owner)
33 end
38 end
34 end
39 end
35
40
36 has_many :member_roles, :dependent => :destroy
41 has_many :member_roles, :dependent => :destroy
37 has_many :members, :through => :member_roles
42 has_many :members, :through => :member_roles
38 acts_as_list
43 acts_as_list
39
44
40 serialize :permissions, Array
45 serialize :permissions, Array
41 attr_protected :builtin
46 attr_protected :builtin
42
47
43 validates_presence_of :name
48 validates_presence_of :name
44 validates_uniqueness_of :name
49 validates_uniqueness_of :name
45 validates_length_of :name, :maximum => 30
50 validates_length_of :name, :maximum => 30
46
51 validates_inclusion_of :issues_visibility,
52 :in => ISSUES_VISIBILITY_OPTIONS.collect(&:first),
53 :if => lambda {|role| role.respond_to?(:issues_visibility)}
54
47 def permissions
55 def permissions
48 read_attribute(:permissions) || []
56 read_attribute(:permissions) || []
49 end
57 end
50
58
51 def permissions=(perms)
59 def permissions=(perms)
52 perms = perms.collect {|p| p.to_sym unless p.blank? }.compact.uniq if perms
60 perms = perms.collect {|p| p.to_sym unless p.blank? }.compact.uniq if perms
53 write_attribute(:permissions, perms)
61 write_attribute(:permissions, perms)
54 end
62 end
55
63
56 def add_permission!(*perms)
64 def add_permission!(*perms)
57 self.permissions = [] unless permissions.is_a?(Array)
65 self.permissions = [] unless permissions.is_a?(Array)
58
66
59 permissions_will_change!
67 permissions_will_change!
60 perms.each do |p|
68 perms.each do |p|
61 p = p.to_sym
69 p = p.to_sym
62 permissions << p unless permissions.include?(p)
70 permissions << p unless permissions.include?(p)
63 end
71 end
64 save!
72 save!
65 end
73 end
66
74
67 def remove_permission!(*perms)
75 def remove_permission!(*perms)
68 return unless permissions.is_a?(Array)
76 return unless permissions.is_a?(Array)
69 permissions_will_change!
77 permissions_will_change!
70 perms.each { |p| permissions.delete(p.to_sym) }
78 perms.each { |p| permissions.delete(p.to_sym) }
71 save!
79 save!
72 end
80 end
73
81
74 # Returns true if the role has the given permission
82 # Returns true if the role has the given permission
75 def has_permission?(perm)
83 def has_permission?(perm)
76 !permissions.nil? && permissions.include?(perm.to_sym)
84 !permissions.nil? && permissions.include?(perm.to_sym)
77 end
85 end
78
86
79 def <=>(role)
87 def <=>(role)
80 role ? position <=> role.position : -1
88 role ? position <=> role.position : -1
81 end
89 end
82
90
83 def to_s
91 def to_s
84 name
92 name
85 end
93 end
86
94
87 def name
95 def name
88 case builtin
96 case builtin
89 when 1; l(:label_role_non_member, :default => read_attribute(:name))
97 when 1; l(:label_role_non_member, :default => read_attribute(:name))
90 when 2; l(:label_role_anonymous, :default => read_attribute(:name))
98 when 2; l(:label_role_anonymous, :default => read_attribute(:name))
91 else; read_attribute(:name)
99 else; read_attribute(:name)
92 end
100 end
93 end
101 end
94
102
95 # Return true if the role is a builtin role
103 # Return true if the role is a builtin role
96 def builtin?
104 def builtin?
97 self.builtin != 0
105 self.builtin != 0
98 end
106 end
99
107
100 # Return true if the role is a project member role
108 # Return true if the role is a project member role
101 def member?
109 def member?
102 !self.builtin?
110 !self.builtin?
103 end
111 end
104
112
105 # Return true if role is allowed to do the specified action
113 # Return true if role is allowed to do the specified action
106 # action can be:
114 # action can be:
107 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
115 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
108 # * a permission Symbol (eg. :edit_project)
116 # * a permission Symbol (eg. :edit_project)
109 def allowed_to?(action)
117 def allowed_to?(action)
110 if action.is_a? Hash
118 if action.is_a? Hash
111 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
119 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
112 else
120 else
113 allowed_permissions.include? action
121 allowed_permissions.include? action
114 end
122 end
115 end
123 end
116
124
117 # Return all the permissions that can be given to the role
125 # Return all the permissions that can be given to the role
118 def setable_permissions
126 def setable_permissions
119 setable_permissions = Redmine::AccessControl.permissions - Redmine::AccessControl.public_permissions
127 setable_permissions = Redmine::AccessControl.permissions - Redmine::AccessControl.public_permissions
120 setable_permissions -= Redmine::AccessControl.members_only_permissions if self.builtin == BUILTIN_NON_MEMBER
128 setable_permissions -= Redmine::AccessControl.members_only_permissions if self.builtin == BUILTIN_NON_MEMBER
121 setable_permissions -= Redmine::AccessControl.loggedin_only_permissions if self.builtin == BUILTIN_ANONYMOUS
129 setable_permissions -= Redmine::AccessControl.loggedin_only_permissions if self.builtin == BUILTIN_ANONYMOUS
122 setable_permissions
130 setable_permissions
123 end
131 end
124
132
125 # Find all the roles that can be given to a project member
133 # Find all the roles that can be given to a project member
126 def self.find_all_givable
134 def self.find_all_givable
127 find(:all, :conditions => {:builtin => 0}, :order => 'position')
135 find(:all, :conditions => {:builtin => 0}, :order => 'position')
128 end
136 end
129
137
130 # Return the builtin 'non member' role. If the role doesn't exist,
138 # Return the builtin 'non member' role. If the role doesn't exist,
131 # it will be created on the fly.
139 # it will be created on the fly.
132 def self.non_member
140 def self.non_member
133 non_member_role = find(:first, :conditions => {:builtin => BUILTIN_NON_MEMBER})
141 non_member_role = find(:first, :conditions => {:builtin => BUILTIN_NON_MEMBER})
134 if non_member_role.nil?
142 if non_member_role.nil?
135 non_member_role = create(:name => 'Non member', :position => 0) do |role|
143 non_member_role = create(:name => 'Non member', :position => 0) do |role|
136 role.builtin = BUILTIN_NON_MEMBER
144 role.builtin = BUILTIN_NON_MEMBER
137 end
145 end
138 raise 'Unable to create the non-member role.' if non_member_role.new_record?
146 raise 'Unable to create the non-member role.' if non_member_role.new_record?
139 end
147 end
140 non_member_role
148 non_member_role
141 end
149 end
142
150
143 # Return the builtin 'anonymous' role. If the role doesn't exist,
151 # Return the builtin 'anonymous' role. If the role doesn't exist,
144 # it will be created on the fly.
152 # it will be created on the fly.
145 def self.anonymous
153 def self.anonymous
146 anonymous_role = find(:first, :conditions => {:builtin => BUILTIN_ANONYMOUS})
154 anonymous_role = find(:first, :conditions => {:builtin => BUILTIN_ANONYMOUS})
147 if anonymous_role.nil?
155 if anonymous_role.nil?
148 anonymous_role = create(:name => 'Anonymous', :position => 0) do |role|
156 anonymous_role = create(:name => 'Anonymous', :position => 0) do |role|
149 role.builtin = BUILTIN_ANONYMOUS
157 role.builtin = BUILTIN_ANONYMOUS
150 end
158 end
151 raise 'Unable to create the anonymous role.' if anonymous_role.new_record?
159 raise 'Unable to create the anonymous role.' if anonymous_role.new_record?
152 end
160 end
153 anonymous_role
161 anonymous_role
154 end
162 end
155
163
156
164
157 private
165 private
158 def allowed_permissions
166 def allowed_permissions
159 @allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
167 @allowed_permissions ||= permissions + Redmine::AccessControl.public_permissions.collect {|p| p.name}
160 end
168 end
161
169
162 def allowed_actions
170 def allowed_actions
163 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
171 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
164 end
172 end
165
173
166 def check_deletable
174 def check_deletable
167 raise "Can't delete role" if members.any?
175 raise "Can't delete role" if members.any?
168 raise "Can't delete builtin role" if builtin?
176 raise "Can't delete builtin role" if builtin?
169 end
177 end
170 end
178 end
@@ -1,599 +1,606
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require "digest/sha1"
18 require "digest/sha1"
19
19
20 class User < Principal
20 class User < Principal
21 include Redmine::SafeAttributes
21 include Redmine::SafeAttributes
22
22
23 # Account statuses
23 # Account statuses
24 STATUS_ANONYMOUS = 0
24 STATUS_ANONYMOUS = 0
25 STATUS_ACTIVE = 1
25 STATUS_ACTIVE = 1
26 STATUS_REGISTERED = 2
26 STATUS_REGISTERED = 2
27 STATUS_LOCKED = 3
27 STATUS_LOCKED = 3
28
28
29 USER_FORMATS = {
29 USER_FORMATS = {
30 :firstname_lastname => '#{firstname} #{lastname}',
30 :firstname_lastname => '#{firstname} #{lastname}',
31 :firstname => '#{firstname}',
31 :firstname => '#{firstname}',
32 :lastname_firstname => '#{lastname} #{firstname}',
32 :lastname_firstname => '#{lastname} #{firstname}',
33 :lastname_coma_firstname => '#{lastname}, #{firstname}',
33 :lastname_coma_firstname => '#{lastname}, #{firstname}',
34 :username => '#{login}'
34 :username => '#{login}'
35 }
35 }
36
36
37 MAIL_NOTIFICATION_OPTIONS = [
37 MAIL_NOTIFICATION_OPTIONS = [
38 ['all', :label_user_mail_option_all],
38 ['all', :label_user_mail_option_all],
39 ['selected', :label_user_mail_option_selected],
39 ['selected', :label_user_mail_option_selected],
40 ['only_my_events', :label_user_mail_option_only_my_events],
40 ['only_my_events', :label_user_mail_option_only_my_events],
41 ['only_assigned', :label_user_mail_option_only_assigned],
41 ['only_assigned', :label_user_mail_option_only_assigned],
42 ['only_owner', :label_user_mail_option_only_owner],
42 ['only_owner', :label_user_mail_option_only_owner],
43 ['none', :label_user_mail_option_none]
43 ['none', :label_user_mail_option_none]
44 ]
44 ]
45
45
46 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
46 has_and_belongs_to_many :groups, :after_add => Proc.new {|user, group| group.user_added(user)},
47 :after_remove => Proc.new {|user, group| group.user_removed(user)}
47 :after_remove => Proc.new {|user, group| group.user_removed(user)}
48 has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
48 has_many :issue_categories, :foreign_key => 'assigned_to_id', :dependent => :nullify
49 has_many :changesets, :dependent => :nullify
49 has_many :changesets, :dependent => :nullify
50 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
50 has_one :preference, :dependent => :destroy, :class_name => 'UserPreference'
51 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
51 has_one :rss_token, :class_name => 'Token', :conditions => "action='feeds'"
52 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
52 has_one :api_token, :class_name => 'Token', :conditions => "action='api'"
53 belongs_to :auth_source
53 belongs_to :auth_source
54
54
55 # Active non-anonymous users scope
55 # Active non-anonymous users scope
56 named_scope :active, :conditions => "#{User.table_name}.status = #{STATUS_ACTIVE}"
56 named_scope :active, :conditions => "#{User.table_name}.status = #{STATUS_ACTIVE}"
57
57
58 acts_as_customizable
58 acts_as_customizable
59
59
60 attr_accessor :password, :password_confirmation
60 attr_accessor :password, :password_confirmation
61 attr_accessor :last_before_login_on
61 attr_accessor :last_before_login_on
62 # Prevents unauthorized assignments
62 # Prevents unauthorized assignments
63 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
63 attr_protected :login, :admin, :password, :password_confirmation, :hashed_password
64
64
65 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
65 validates_presence_of :login, :firstname, :lastname, :mail, :if => Proc.new { |user| !user.is_a?(AnonymousUser) }
66 validates_uniqueness_of :login, :if => Proc.new { |user| !user.login.blank? }, :case_sensitive => false
66 validates_uniqueness_of :login, :if => Proc.new { |user| !user.login.blank? }, :case_sensitive => false
67 validates_uniqueness_of :mail, :if => Proc.new { |user| !user.mail.blank? }, :case_sensitive => false
67 validates_uniqueness_of :mail, :if => Proc.new { |user| !user.mail.blank? }, :case_sensitive => false
68 # Login must contain lettres, numbers, underscores only
68 # Login must contain lettres, numbers, underscores only
69 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
69 validates_format_of :login, :with => /^[a-z0-9_\-@\.]*$/i
70 validates_length_of :login, :maximum => 30
70 validates_length_of :login, :maximum => 30
71 validates_length_of :firstname, :lastname, :maximum => 30
71 validates_length_of :firstname, :lastname, :maximum => 30
72 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_nil => true
72 validates_format_of :mail, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i, :allow_nil => true
73 validates_length_of :mail, :maximum => 60, :allow_nil => true
73 validates_length_of :mail, :maximum => 60, :allow_nil => true
74 validates_confirmation_of :password, :allow_nil => true
74 validates_confirmation_of :password, :allow_nil => true
75 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
75 validates_inclusion_of :mail_notification, :in => MAIL_NOTIFICATION_OPTIONS.collect(&:first), :allow_blank => true
76
76
77 before_destroy :remove_references_before_destroy
77 before_destroy :remove_references_before_destroy
78
78
79 named_scope :in_group, lambda {|group|
79 named_scope :in_group, lambda {|group|
80 group_id = group.is_a?(Group) ? group.id : group.to_i
80 group_id = group.is_a?(Group) ? group.id : group.to_i
81 { :conditions => ["#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id] }
81 { :conditions => ["#{User.table_name}.id IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id] }
82 }
82 }
83 named_scope :not_in_group, lambda {|group|
83 named_scope :not_in_group, lambda {|group|
84 group_id = group.is_a?(Group) ? group.id : group.to_i
84 group_id = group.is_a?(Group) ? group.id : group.to_i
85 { :conditions => ["#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id] }
85 { :conditions => ["#{User.table_name}.id NOT IN (SELECT gu.user_id FROM #{table_name_prefix}groups_users#{table_name_suffix} gu WHERE gu.group_id = ?)", group_id] }
86 }
86 }
87
87
88 def before_create
88 def before_create
89 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
89 self.mail_notification = Setting.default_notification_option if self.mail_notification.blank?
90 true
90 true
91 end
91 end
92
92
93 def before_save
93 def before_save
94 # update hashed_password if password was set
94 # update hashed_password if password was set
95 if self.password && self.auth_source_id.blank?
95 if self.password && self.auth_source_id.blank?
96 salt_password(password)
96 salt_password(password)
97 end
97 end
98 end
98 end
99
99
100 def reload(*args)
100 def reload(*args)
101 @name = nil
101 @name = nil
102 @projects_by_role = nil
102 @projects_by_role = nil
103 super
103 super
104 end
104 end
105
105
106 def mail=(arg)
106 def mail=(arg)
107 write_attribute(:mail, arg.to_s.strip)
107 write_attribute(:mail, arg.to_s.strip)
108 end
108 end
109
109
110 def identity_url=(url)
110 def identity_url=(url)
111 if url.blank?
111 if url.blank?
112 write_attribute(:identity_url, '')
112 write_attribute(:identity_url, '')
113 else
113 else
114 begin
114 begin
115 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
115 write_attribute(:identity_url, OpenIdAuthentication.normalize_identifier(url))
116 rescue OpenIdAuthentication::InvalidOpenId
116 rescue OpenIdAuthentication::InvalidOpenId
117 # Invlaid url, don't save
117 # Invlaid url, don't save
118 end
118 end
119 end
119 end
120 self.read_attribute(:identity_url)
120 self.read_attribute(:identity_url)
121 end
121 end
122
122
123 # Returns the user that matches provided login and password, or nil
123 # Returns the user that matches provided login and password, or nil
124 def self.try_to_login(login, password)
124 def self.try_to_login(login, password)
125 # Make sure no one can sign in with an empty password
125 # Make sure no one can sign in with an empty password
126 return nil if password.to_s.empty?
126 return nil if password.to_s.empty?
127 user = find_by_login(login)
127 user = find_by_login(login)
128 if user
128 if user
129 # user is already in local database
129 # user is already in local database
130 return nil if !user.active?
130 return nil if !user.active?
131 if user.auth_source
131 if user.auth_source
132 # user has an external authentication method
132 # user has an external authentication method
133 return nil unless user.auth_source.authenticate(login, password)
133 return nil unless user.auth_source.authenticate(login, password)
134 else
134 else
135 # authentication with local password
135 # authentication with local password
136 return nil unless user.check_password?(password)
136 return nil unless user.check_password?(password)
137 end
137 end
138 else
138 else
139 # user is not yet registered, try to authenticate with available sources
139 # user is not yet registered, try to authenticate with available sources
140 attrs = AuthSource.authenticate(login, password)
140 attrs = AuthSource.authenticate(login, password)
141 if attrs
141 if attrs
142 user = new(attrs)
142 user = new(attrs)
143 user.login = login
143 user.login = login
144 user.language = Setting.default_language
144 user.language = Setting.default_language
145 if user.save
145 if user.save
146 user.reload
146 user.reload
147 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
147 logger.info("User '#{user.login}' created from external auth source: #{user.auth_source.type} - #{user.auth_source.name}") if logger && user.auth_source
148 end
148 end
149 end
149 end
150 end
150 end
151 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
151 user.update_attribute(:last_login_on, Time.now) if user && !user.new_record?
152 user
152 user
153 rescue => text
153 rescue => text
154 raise text
154 raise text
155 end
155 end
156
156
157 # Returns the user who matches the given autologin +key+ or nil
157 # Returns the user who matches the given autologin +key+ or nil
158 def self.try_to_autologin(key)
158 def self.try_to_autologin(key)
159 tokens = Token.find_all_by_action_and_value('autologin', key)
159 tokens = Token.find_all_by_action_and_value('autologin', key)
160 # Make sure there's only 1 token that matches the key
160 # Make sure there's only 1 token that matches the key
161 if tokens.size == 1
161 if tokens.size == 1
162 token = tokens.first
162 token = tokens.first
163 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
163 if (token.created_on > Setting.autologin.to_i.day.ago) && token.user && token.user.active?
164 token.user.update_attribute(:last_login_on, Time.now)
164 token.user.update_attribute(:last_login_on, Time.now)
165 token.user
165 token.user
166 end
166 end
167 end
167 end
168 end
168 end
169
169
170 # Return user's full name for display
170 # Return user's full name for display
171 def name(formatter = nil)
171 def name(formatter = nil)
172 if formatter
172 if formatter
173 eval('"' + (USER_FORMATS[formatter] || USER_FORMATS[:firstname_lastname]) + '"')
173 eval('"' + (USER_FORMATS[formatter] || USER_FORMATS[:firstname_lastname]) + '"')
174 else
174 else
175 @name ||= eval('"' + (USER_FORMATS[Setting.user_format] || USER_FORMATS[:firstname_lastname]) + '"')
175 @name ||= eval('"' + (USER_FORMATS[Setting.user_format] || USER_FORMATS[:firstname_lastname]) + '"')
176 end
176 end
177 end
177 end
178
178
179 def active?
179 def active?
180 self.status == STATUS_ACTIVE
180 self.status == STATUS_ACTIVE
181 end
181 end
182
182
183 def registered?
183 def registered?
184 self.status == STATUS_REGISTERED
184 self.status == STATUS_REGISTERED
185 end
185 end
186
186
187 def locked?
187 def locked?
188 self.status == STATUS_LOCKED
188 self.status == STATUS_LOCKED
189 end
189 end
190
190
191 def activate
191 def activate
192 self.status = STATUS_ACTIVE
192 self.status = STATUS_ACTIVE
193 end
193 end
194
194
195 def register
195 def register
196 self.status = STATUS_REGISTERED
196 self.status = STATUS_REGISTERED
197 end
197 end
198
198
199 def lock
199 def lock
200 self.status = STATUS_LOCKED
200 self.status = STATUS_LOCKED
201 end
201 end
202
202
203 def activate!
203 def activate!
204 update_attribute(:status, STATUS_ACTIVE)
204 update_attribute(:status, STATUS_ACTIVE)
205 end
205 end
206
206
207 def register!
207 def register!
208 update_attribute(:status, STATUS_REGISTERED)
208 update_attribute(:status, STATUS_REGISTERED)
209 end
209 end
210
210
211 def lock!
211 def lock!
212 update_attribute(:status, STATUS_LOCKED)
212 update_attribute(:status, STATUS_LOCKED)
213 end
213 end
214
214
215 # Returns true if +clear_password+ is the correct user's password, otherwise false
215 # Returns true if +clear_password+ is the correct user's password, otherwise false
216 def check_password?(clear_password)
216 def check_password?(clear_password)
217 if auth_source_id.present?
217 if auth_source_id.present?
218 auth_source.authenticate(self.login, clear_password)
218 auth_source.authenticate(self.login, clear_password)
219 else
219 else
220 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
220 User.hash_password("#{salt}#{User.hash_password clear_password}") == hashed_password
221 end
221 end
222 end
222 end
223
223
224 # Generates a random salt and computes hashed_password for +clear_password+
224 # Generates a random salt and computes hashed_password for +clear_password+
225 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
225 # The hashed password is stored in the following form: SHA1(salt + SHA1(password))
226 def salt_password(clear_password)
226 def salt_password(clear_password)
227 self.salt = User.generate_salt
227 self.salt = User.generate_salt
228 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
228 self.hashed_password = User.hash_password("#{salt}#{User.hash_password clear_password}")
229 end
229 end
230
230
231 # Does the backend storage allow this user to change their password?
231 # Does the backend storage allow this user to change their password?
232 def change_password_allowed?
232 def change_password_allowed?
233 return true if auth_source_id.blank?
233 return true if auth_source_id.blank?
234 return auth_source.allow_password_changes?
234 return auth_source.allow_password_changes?
235 end
235 end
236
236
237 # Generate and set a random password. Useful for automated user creation
237 # Generate and set a random password. Useful for automated user creation
238 # Based on Token#generate_token_value
238 # Based on Token#generate_token_value
239 #
239 #
240 def random_password
240 def random_password
241 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
241 chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a
242 password = ''
242 password = ''
243 40.times { |i| password << chars[rand(chars.size-1)] }
243 40.times { |i| password << chars[rand(chars.size-1)] }
244 self.password = password
244 self.password = password
245 self.password_confirmation = password
245 self.password_confirmation = password
246 self
246 self
247 end
247 end
248
248
249 def pref
249 def pref
250 self.preference ||= UserPreference.new(:user => self)
250 self.preference ||= UserPreference.new(:user => self)
251 end
251 end
252
252
253 def time_zone
253 def time_zone
254 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
254 @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
255 end
255 end
256
256
257 def wants_comments_in_reverse_order?
257 def wants_comments_in_reverse_order?
258 self.pref[:comments_sorting] == 'desc'
258 self.pref[:comments_sorting] == 'desc'
259 end
259 end
260
260
261 # Return user's RSS key (a 40 chars long string), used to access feeds
261 # Return user's RSS key (a 40 chars long string), used to access feeds
262 def rss_key
262 def rss_key
263 token = self.rss_token || Token.create(:user => self, :action => 'feeds')
263 token = self.rss_token || Token.create(:user => self, :action => 'feeds')
264 token.value
264 token.value
265 end
265 end
266
266
267 # Return user's API key (a 40 chars long string), used to access the API
267 # Return user's API key (a 40 chars long string), used to access the API
268 def api_key
268 def api_key
269 token = self.api_token || self.create_api_token(:action => 'api')
269 token = self.api_token || self.create_api_token(:action => 'api')
270 token.value
270 token.value
271 end
271 end
272
272
273 # Return an array of project ids for which the user has explicitly turned mail notifications on
273 # Return an array of project ids for which the user has explicitly turned mail notifications on
274 def notified_projects_ids
274 def notified_projects_ids
275 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
275 @notified_projects_ids ||= memberships.select {|m| m.mail_notification?}.collect(&:project_id)
276 end
276 end
277
277
278 def notified_project_ids=(ids)
278 def notified_project_ids=(ids)
279 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
279 Member.update_all("mail_notification = #{connection.quoted_false}", ['user_id = ?', id])
280 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
280 Member.update_all("mail_notification = #{connection.quoted_true}", ['user_id = ? AND project_id IN (?)', id, ids]) if ids && !ids.empty?
281 @notified_projects_ids = nil
281 @notified_projects_ids = nil
282 notified_projects_ids
282 notified_projects_ids
283 end
283 end
284
284
285 def valid_notification_options
285 def valid_notification_options
286 self.class.valid_notification_options(self)
286 self.class.valid_notification_options(self)
287 end
287 end
288
288
289 # Only users that belong to more than 1 project can select projects for which they are notified
289 # Only users that belong to more than 1 project can select projects for which they are notified
290 def self.valid_notification_options(user=nil)
290 def self.valid_notification_options(user=nil)
291 # Note that @user.membership.size would fail since AR ignores
291 # Note that @user.membership.size would fail since AR ignores
292 # :include association option when doing a count
292 # :include association option when doing a count
293 if user.nil? || user.memberships.length < 1
293 if user.nil? || user.memberships.length < 1
294 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
294 MAIL_NOTIFICATION_OPTIONS.reject {|option| option.first == 'selected'}
295 else
295 else
296 MAIL_NOTIFICATION_OPTIONS
296 MAIL_NOTIFICATION_OPTIONS
297 end
297 end
298 end
298 end
299
299
300 # Find a user account by matching the exact login and then a case-insensitive
300 # Find a user account by matching the exact login and then a case-insensitive
301 # version. Exact matches will be given priority.
301 # version. Exact matches will be given priority.
302 def self.find_by_login(login)
302 def self.find_by_login(login)
303 # force string comparison to be case sensitive on MySQL
303 # force string comparison to be case sensitive on MySQL
304 type_cast = (ActiveRecord::Base.connection.adapter_name == 'MySQL') ? 'BINARY' : ''
304 type_cast = (ActiveRecord::Base.connection.adapter_name == 'MySQL') ? 'BINARY' : ''
305
305
306 # First look for an exact match
306 # First look for an exact match
307 user = first(:conditions => ["#{type_cast} login = ?", login])
307 user = first(:conditions => ["#{type_cast} login = ?", login])
308 # Fail over to case-insensitive if none was found
308 # Fail over to case-insensitive if none was found
309 user ||= first(:conditions => ["#{type_cast} LOWER(login) = ?", login.to_s.downcase])
309 user ||= first(:conditions => ["#{type_cast} LOWER(login) = ?", login.to_s.downcase])
310 end
310 end
311
311
312 def self.find_by_rss_key(key)
312 def self.find_by_rss_key(key)
313 token = Token.find_by_value(key)
313 token = Token.find_by_value(key)
314 token && token.user.active? ? token.user : nil
314 token && token.user.active? ? token.user : nil
315 end
315 end
316
316
317 def self.find_by_api_key(key)
317 def self.find_by_api_key(key)
318 token = Token.find_by_action_and_value('api', key)
318 token = Token.find_by_action_and_value('api', key)
319 token && token.user.active? ? token.user : nil
319 token && token.user.active? ? token.user : nil
320 end
320 end
321
321
322 # Makes find_by_mail case-insensitive
322 # Makes find_by_mail case-insensitive
323 def self.find_by_mail(mail)
323 def self.find_by_mail(mail)
324 find(:first, :conditions => ["LOWER(mail) = ?", mail.to_s.downcase])
324 find(:first, :conditions => ["LOWER(mail) = ?", mail.to_s.downcase])
325 end
325 end
326
326
327 def to_s
327 def to_s
328 name
328 name
329 end
329 end
330
330
331 # Returns the current day according to user's time zone
331 # Returns the current day according to user's time zone
332 def today
332 def today
333 if time_zone.nil?
333 if time_zone.nil?
334 Date.today
334 Date.today
335 else
335 else
336 Time.now.in_time_zone(time_zone).to_date
336 Time.now.in_time_zone(time_zone).to_date
337 end
337 end
338 end
338 end
339
339
340 def logged?
340 def logged?
341 true
341 true
342 end
342 end
343
343
344 def anonymous?
344 def anonymous?
345 !logged?
345 !logged?
346 end
346 end
347
347
348 # Return user's roles for project
348 # Return user's roles for project
349 def roles_for_project(project)
349 def roles_for_project(project)
350 roles = []
350 roles = []
351 # No role on archived projects
351 # No role on archived projects
352 return roles unless project && project.active?
352 return roles unless project && project.active?
353 if logged?
353 if logged?
354 # Find project membership
354 # Find project membership
355 membership = memberships.detect {|m| m.project_id == project.id}
355 membership = memberships.detect {|m| m.project_id == project.id}
356 if membership
356 if membership
357 roles = membership.roles
357 roles = membership.roles
358 else
358 else
359 @role_non_member ||= Role.non_member
359 @role_non_member ||= Role.non_member
360 roles << @role_non_member
360 roles << @role_non_member
361 end
361 end
362 else
362 else
363 @role_anonymous ||= Role.anonymous
363 @role_anonymous ||= Role.anonymous
364 roles << @role_anonymous
364 roles << @role_anonymous
365 end
365 end
366 roles
366 roles
367 end
367 end
368
368
369 # Return true if the user is a member of project
369 # Return true if the user is a member of project
370 def member_of?(project)
370 def member_of?(project)
371 !roles_for_project(project).detect {|role| role.member?}.nil?
371 !roles_for_project(project).detect {|role| role.member?}.nil?
372 end
372 end
373
373
374 # Returns a hash of user's projects grouped by roles
374 # Returns a hash of user's projects grouped by roles
375 def projects_by_role
375 def projects_by_role
376 return @projects_by_role if @projects_by_role
376 return @projects_by_role if @projects_by_role
377
377
378 @projects_by_role = Hash.new {|h,k| h[k]=[]}
378 @projects_by_role = Hash.new {|h,k| h[k]=[]}
379 memberships.each do |membership|
379 memberships.each do |membership|
380 membership.roles.each do |role|
380 membership.roles.each do |role|
381 @projects_by_role[role] << membership.project if membership.project
381 @projects_by_role[role] << membership.project if membership.project
382 end
382 end
383 end
383 end
384 @projects_by_role.each do |role, projects|
384 @projects_by_role.each do |role, projects|
385 projects.uniq!
385 projects.uniq!
386 end
386 end
387
387
388 @projects_by_role
388 @projects_by_role
389 end
389 end
390
390
391 # Return true if the user is allowed to do the specified action on a specific context
391 # Return true if the user is allowed to do the specified action on a specific context
392 # Action can be:
392 # Action can be:
393 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
393 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
394 # * a permission Symbol (eg. :edit_project)
394 # * a permission Symbol (eg. :edit_project)
395 # Context can be:
395 # Context can be:
396 # * a project : returns true if user is allowed to do the specified action on this project
396 # * a project : returns true if user is allowed to do the specified action on this project
397 # * a group of projects : returns true if user is allowed on every project
397 # * an array of projects : returns true if user is allowed on every project
398 # * nil with options[:global] set : check if user has at least one role allowed for this action,
398 # * nil with options[:global] set : check if user has at least one role allowed for this action,
399 # or falls back to Non Member / Anonymous permissions depending if the user is logged
399 # or falls back to Non Member / Anonymous permissions depending if the user is logged
400 def allowed_to?(action, context, options={})
400 def allowed_to?(action, context, options={}, &block)
401 if context && context.is_a?(Project)
401 if context && context.is_a?(Project)
402 # No action allowed on archived projects
402 # No action allowed on archived projects
403 return false unless context.active?
403 return false unless context.active?
404 # No action allowed on disabled modules
404 # No action allowed on disabled modules
405 return false unless context.allows_to?(action)
405 return false unless context.allows_to?(action)
406 # Admin users are authorized for anything else
406 # Admin users are authorized for anything else
407 return true if admin?
407 return true if admin?
408
408
409 roles = roles_for_project(context)
409 roles = roles_for_project(context)
410 return false unless roles
410 return false unless roles
411 roles.detect {|role| (context.is_public? || role.member?) && role.allowed_to?(action)}
411 roles.detect {|role|
412
412 (context.is_public? || role.member?) &&
413 role.allowed_to?(action) &&
414 (block_given? ? yield(role, self) : true)
415 }
413 elsif context && context.is_a?(Array)
416 elsif context && context.is_a?(Array)
414 # Authorize if user is authorized on every element of the array
417 # Authorize if user is authorized on every element of the array
415 context.map do |project|
418 context.map do |project|
416 allowed_to?(action,project,options)
419 allowed_to?(action, project, options, &block)
417 end.inject do |memo,allowed|
420 end.inject do |memo,allowed|
418 memo && allowed
421 memo && allowed
419 end
422 end
420 elsif options[:global]
423 elsif options[:global]
421 # Admin users are always authorized
424 # Admin users are always authorized
422 return true if admin?
425 return true if admin?
423
426
424 # authorize if user has at least one role that has this permission
427 # authorize if user has at least one role that has this permission
425 roles = memberships.collect {|m| m.roles}.flatten.uniq
428 roles = memberships.collect {|m| m.roles}.flatten.uniq
426 roles.detect {|r| r.allowed_to?(action)} || (self.logged? ? Role.non_member.allowed_to?(action) : Role.anonymous.allowed_to?(action))
429 roles << (self.logged? ? Role.non_member : Role.anonymous)
430 roles.detect {|role|
431 role.allowed_to?(action) &&
432 (block_given? ? yield(role, self) : true)
433 }
427 else
434 else
428 false
435 false
429 end
436 end
430 end
437 end
431
438
432 # Is the user allowed to do the specified action on any project?
439 # Is the user allowed to do the specified action on any project?
433 # See allowed_to? for the actions and valid options.
440 # See allowed_to? for the actions and valid options.
434 def allowed_to_globally?(action, options)
441 def allowed_to_globally?(action, options, &block)
435 allowed_to?(action, nil, options.reverse_merge(:global => true))
442 allowed_to?(action, nil, options.reverse_merge(:global => true), &block)
436 end
443 end
437
444
438 safe_attributes 'login',
445 safe_attributes 'login',
439 'firstname',
446 'firstname',
440 'lastname',
447 'lastname',
441 'mail',
448 'mail',
442 'mail_notification',
449 'mail_notification',
443 'language',
450 'language',
444 'custom_field_values',
451 'custom_field_values',
445 'custom_fields',
452 'custom_fields',
446 'identity_url'
453 'identity_url'
447
454
448 safe_attributes 'status',
455 safe_attributes 'status',
449 'auth_source_id',
456 'auth_source_id',
450 :if => lambda {|user, current_user| current_user.admin?}
457 :if => lambda {|user, current_user| current_user.admin?}
451
458
452 safe_attributes 'group_ids',
459 safe_attributes 'group_ids',
453 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
460 :if => lambda {|user, current_user| current_user.admin? && !user.new_record?}
454
461
455 # Utility method to help check if a user should be notified about an
462 # Utility method to help check if a user should be notified about an
456 # event.
463 # event.
457 #
464 #
458 # TODO: only supports Issue events currently
465 # TODO: only supports Issue events currently
459 def notify_about?(object)
466 def notify_about?(object)
460 case mail_notification
467 case mail_notification
461 when 'all'
468 when 'all'
462 true
469 true
463 when 'selected'
470 when 'selected'
464 # user receives notifications for created/assigned issues on unselected projects
471 # user receives notifications for created/assigned issues on unselected projects
465 if object.is_a?(Issue) && (object.author == self || object.assigned_to == self)
472 if object.is_a?(Issue) && (object.author == self || object.assigned_to == self)
466 true
473 true
467 else
474 else
468 false
475 false
469 end
476 end
470 when 'none'
477 when 'none'
471 false
478 false
472 when 'only_my_events'
479 when 'only_my_events'
473 if object.is_a?(Issue) && (object.author == self || object.assigned_to == self)
480 if object.is_a?(Issue) && (object.author == self || object.assigned_to == self)
474 true
481 true
475 else
482 else
476 false
483 false
477 end
484 end
478 when 'only_assigned'
485 when 'only_assigned'
479 if object.is_a?(Issue) && object.assigned_to == self
486 if object.is_a?(Issue) && object.assigned_to == self
480 true
487 true
481 else
488 else
482 false
489 false
483 end
490 end
484 when 'only_owner'
491 when 'only_owner'
485 if object.is_a?(Issue) && object.author == self
492 if object.is_a?(Issue) && object.author == self
486 true
493 true
487 else
494 else
488 false
495 false
489 end
496 end
490 else
497 else
491 false
498 false
492 end
499 end
493 end
500 end
494
501
495 def self.current=(user)
502 def self.current=(user)
496 @current_user = user
503 @current_user = user
497 end
504 end
498
505
499 def self.current
506 def self.current
500 @current_user ||= User.anonymous
507 @current_user ||= User.anonymous
501 end
508 end
502
509
503 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
510 # Returns the anonymous user. If the anonymous user does not exist, it is created. There can be only
504 # one anonymous user per database.
511 # one anonymous user per database.
505 def self.anonymous
512 def self.anonymous
506 anonymous_user = AnonymousUser.find(:first)
513 anonymous_user = AnonymousUser.find(:first)
507 if anonymous_user.nil?
514 if anonymous_user.nil?
508 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
515 anonymous_user = AnonymousUser.create(:lastname => 'Anonymous', :firstname => '', :mail => '', :login => '', :status => 0)
509 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
516 raise 'Unable to create the anonymous user.' if anonymous_user.new_record?
510 end
517 end
511 anonymous_user
518 anonymous_user
512 end
519 end
513
520
514 # Salts all existing unsalted passwords
521 # Salts all existing unsalted passwords
515 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
522 # It changes password storage scheme from SHA1(password) to SHA1(salt + SHA1(password))
516 # This method is used in the SaltPasswords migration and is to be kept as is
523 # This method is used in the SaltPasswords migration and is to be kept as is
517 def self.salt_unsalted_passwords!
524 def self.salt_unsalted_passwords!
518 transaction do
525 transaction do
519 User.find_each(:conditions => "salt IS NULL OR salt = ''") do |user|
526 User.find_each(:conditions => "salt IS NULL OR salt = ''") do |user|
520 next if user.hashed_password.blank?
527 next if user.hashed_password.blank?
521 salt = User.generate_salt
528 salt = User.generate_salt
522 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
529 hashed_password = User.hash_password("#{salt}#{user.hashed_password}")
523 User.update_all("salt = '#{salt}', hashed_password = '#{hashed_password}'", ["id = ?", user.id] )
530 User.update_all("salt = '#{salt}', hashed_password = '#{hashed_password}'", ["id = ?", user.id] )
524 end
531 end
525 end
532 end
526 end
533 end
527
534
528 protected
535 protected
529
536
530 def validate
537 def validate
531 # Password length validation based on setting
538 # Password length validation based on setting
532 if !password.nil? && password.size < Setting.password_min_length.to_i
539 if !password.nil? && password.size < Setting.password_min_length.to_i
533 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
540 errors.add(:password, :too_short, :count => Setting.password_min_length.to_i)
534 end
541 end
535 end
542 end
536
543
537 private
544 private
538
545
539 # Removes references that are not handled by associations
546 # Removes references that are not handled by associations
540 # Things that are not deleted are reassociated with the anonymous user
547 # Things that are not deleted are reassociated with the anonymous user
541 def remove_references_before_destroy
548 def remove_references_before_destroy
542 return if self.id.nil?
549 return if self.id.nil?
543
550
544 substitute = User.anonymous
551 substitute = User.anonymous
545 Attachment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
552 Attachment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
546 Comment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
553 Comment.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
547 Issue.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
554 Issue.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
548 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
555 Issue.update_all 'assigned_to_id = NULL', ['assigned_to_id = ?', id]
549 Journal.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
556 Journal.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
550 JournalDetail.update_all ['old_value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]
557 JournalDetail.update_all ['old_value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND old_value = ?", id.to_s]
551 JournalDetail.update_all ['value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]
558 JournalDetail.update_all ['value = ?', substitute.id.to_s], ["property = 'attr' AND prop_key = 'assigned_to_id' AND value = ?", id.to_s]
552 Message.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
559 Message.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
553 News.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
560 News.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
554 # Remove private queries and keep public ones
561 # Remove private queries and keep public ones
555 Query.delete_all ['user_id = ? AND is_public = ?', id, false]
562 Query.delete_all ['user_id = ? AND is_public = ?', id, false]
556 Query.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
563 Query.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
557 TimeEntry.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
564 TimeEntry.update_all ['user_id = ?', substitute.id], ['user_id = ?', id]
558 Token.delete_all ['user_id = ?', id]
565 Token.delete_all ['user_id = ?', id]
559 Watcher.delete_all ['user_id = ?', id]
566 Watcher.delete_all ['user_id = ?', id]
560 WikiContent.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
567 WikiContent.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
561 WikiContent::Version.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
568 WikiContent::Version.update_all ['author_id = ?', substitute.id], ['author_id = ?', id]
562 end
569 end
563
570
564 # Return password digest
571 # Return password digest
565 def self.hash_password(clear_password)
572 def self.hash_password(clear_password)
566 Digest::SHA1.hexdigest(clear_password || "")
573 Digest::SHA1.hexdigest(clear_password || "")
567 end
574 end
568
575
569 # Returns a 128bits random salt as a hex string (32 chars long)
576 # Returns a 128bits random salt as a hex string (32 chars long)
570 def self.generate_salt
577 def self.generate_salt
571 ActiveSupport::SecureRandom.hex(16)
578 ActiveSupport::SecureRandom.hex(16)
572 end
579 end
573
580
574 end
581 end
575
582
576 class AnonymousUser < User
583 class AnonymousUser < User
577
584
578 def validate_on_create
585 def validate_on_create
579 # There should be only one AnonymousUser in the database
586 # There should be only one AnonymousUser in the database
580 errors.add_to_base 'An anonymous user already exists.' if AnonymousUser.find(:first)
587 errors.add_to_base 'An anonymous user already exists.' if AnonymousUser.find(:first)
581 end
588 end
582
589
583 def available_custom_fields
590 def available_custom_fields
584 []
591 []
585 end
592 end
586
593
587 # Overrides a few properties
594 # Overrides a few properties
588 def logged?; false end
595 def logged?; false end
589 def admin; false end
596 def admin; false end
590 def name(*args); I18n.t(:label_user_anonymous) end
597 def name(*args); I18n.t(:label_user_anonymous) end
591 def mail; nil end
598 def mail; nil end
592 def time_zone; nil end
599 def time_zone; nil end
593 def rss_key; nil end
600 def rss_key; nil end
594
601
595 # Anonymous user can not be destroyed
602 # Anonymous user can not be destroyed
596 def destroy
603 def destroy
597 false
604 false
598 end
605 end
599 end
606 end
@@ -1,29 +1,30
1 <%= error_messages_for 'role' %>
1 <%= error_messages_for 'role' %>
2
2
3 <% unless @role.builtin? %>
4 <div class="box">
3 <div class="box">
4 <% unless @role.builtin? %>
5 <p><%= f.text_field :name, :required => true %></p>
5 <p><%= f.text_field :name, :required => true %></p>
6 <p><%= f.check_box :assignable %></p>
6 <p><%= f.check_box :assignable %></p>
7 <% end %>
8 <p><%= f.select :issues_visibility, Role::ISSUES_VISIBILITY_OPTIONS.collect {|v| [l(v.last), v.first]} %></p>
7 <% if @role.new_record? && @roles.any? %>
9 <% if @role.new_record? && @roles.any? %>
8 <p><label><%= l(:label_copy_workflow_from) %></label>
10 <p><label><%= l(:label_copy_workflow_from) %></label>
9 <%= select_tag(:copy_workflow_from, content_tag("option") + options_from_collection_for_select(@roles, :id, :name)) %></p>
11 <%= select_tag(:copy_workflow_from, content_tag("option") + options_from_collection_for_select(@roles, :id, :name)) %></p>
10 <% end %>
12 <% end %>
11 </div>
13 </div>
12 <% end %>
13
14
14 <h3><%= l(:label_permissions) %></h3>
15 <h3><%= l(:label_permissions) %></h3>
15 <div class="box" id="permissions">
16 <div class="box" id="permissions">
16 <% perms_by_module = @permissions.group_by {|p| p.project_module.to_s} %>
17 <% perms_by_module = @permissions.group_by {|p| p.project_module.to_s} %>
17 <% perms_by_module.keys.sort.each do |mod| %>
18 <% perms_by_module.keys.sort.each do |mod| %>
18 <fieldset><legend><%= mod.blank? ? l(:label_project) : l_or_humanize(mod, :prefix => 'project_module_') %></legend>
19 <fieldset><legend><%= mod.blank? ? l(:label_project) : l_or_humanize(mod, :prefix => 'project_module_') %></legend>
19 <% perms_by_module[mod].each do |permission| %>
20 <% perms_by_module[mod].each do |permission| %>
20 <label class="floating">
21 <label class="floating">
21 <%= check_box_tag 'role[permissions][]', permission.name, (@role.permissions.include? permission.name) %>
22 <%= check_box_tag 'role[permissions][]', permission.name, (@role.permissions.include? permission.name) %>
22 <%= l_or_humanize(permission.name, :prefix => 'permission_') %>
23 <%= l_or_humanize(permission.name, :prefix => 'permission_') %>
23 </label>
24 </label>
24 <% end %>
25 <% end %>
25 </fieldset>
26 </fieldset>
26 <% end %>
27 <% end %>
27 <br /><%= check_all_links 'permissions' %>
28 <br /><%= check_all_links 'permissions' %>
28 <%= hidden_field_tag 'role[permissions][]', '' %>
29 <%= hidden_field_tag 'role[permissions][]', '' %>
29 </div>
30 </div>
@@ -1,953 +1,956
1 en:
1 en:
2 # Text direction: Left-to-Right (ltr) or Right-to-Left (rtl)
2 # Text direction: Left-to-Right (ltr) or Right-to-Left (rtl)
3 direction: ltr
3 direction: ltr
4 date:
4 date:
5 formats:
5 formats:
6 # Use the strftime parameters for formats.
6 # Use the strftime parameters for formats.
7 # When no format has been given, it uses default.
7 # When no format has been given, it uses default.
8 # You can provide other formats here if you like!
8 # You can provide other formats here if you like!
9 default: "%m/%d/%Y"
9 default: "%m/%d/%Y"
10 short: "%b %d"
10 short: "%b %d"
11 long: "%B %d, %Y"
11 long: "%B %d, %Y"
12
12
13 day_names: [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday]
13 day_names: [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday]
14 abbr_day_names: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
14 abbr_day_names: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
15
15
16 # Don't forget the nil at the beginning; there's no such thing as a 0th month
16 # Don't forget the nil at the beginning; there's no such thing as a 0th month
17 month_names: [~, January, February, March, April, May, June, July, August, September, October, November, December]
17 month_names: [~, January, February, March, April, May, June, July, August, September, October, November, December]
18 abbr_month_names: [~, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
18 abbr_month_names: [~, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
19 # Used in date_select and datime_select.
19 # Used in date_select and datime_select.
20 order: [ :year, :month, :day ]
20 order: [ :year, :month, :day ]
21
21
22 time:
22 time:
23 formats:
23 formats:
24 default: "%m/%d/%Y %I:%M %p"
24 default: "%m/%d/%Y %I:%M %p"
25 time: "%I:%M %p"
25 time: "%I:%M %p"
26 short: "%d %b %H:%M"
26 short: "%d %b %H:%M"
27 long: "%B %d, %Y %H:%M"
27 long: "%B %d, %Y %H:%M"
28 am: "am"
28 am: "am"
29 pm: "pm"
29 pm: "pm"
30
30
31 datetime:
31 datetime:
32 distance_in_words:
32 distance_in_words:
33 half_a_minute: "half a minute"
33 half_a_minute: "half a minute"
34 less_than_x_seconds:
34 less_than_x_seconds:
35 one: "less than 1 second"
35 one: "less than 1 second"
36 other: "less than %{count} seconds"
36 other: "less than %{count} seconds"
37 x_seconds:
37 x_seconds:
38 one: "1 second"
38 one: "1 second"
39 other: "%{count} seconds"
39 other: "%{count} seconds"
40 less_than_x_minutes:
40 less_than_x_minutes:
41 one: "less than a minute"
41 one: "less than a minute"
42 other: "less than %{count} minutes"
42 other: "less than %{count} minutes"
43 x_minutes:
43 x_minutes:
44 one: "1 minute"
44 one: "1 minute"
45 other: "%{count} minutes"
45 other: "%{count} minutes"
46 about_x_hours:
46 about_x_hours:
47 one: "about 1 hour"
47 one: "about 1 hour"
48 other: "about %{count} hours"
48 other: "about %{count} hours"
49 x_days:
49 x_days:
50 one: "1 day"
50 one: "1 day"
51 other: "%{count} days"
51 other: "%{count} days"
52 about_x_months:
52 about_x_months:
53 one: "about 1 month"
53 one: "about 1 month"
54 other: "about %{count} months"
54 other: "about %{count} months"
55 x_months:
55 x_months:
56 one: "1 month"
56 one: "1 month"
57 other: "%{count} months"
57 other: "%{count} months"
58 about_x_years:
58 about_x_years:
59 one: "about 1 year"
59 one: "about 1 year"
60 other: "about %{count} years"
60 other: "about %{count} years"
61 over_x_years:
61 over_x_years:
62 one: "over 1 year"
62 one: "over 1 year"
63 other: "over %{count} years"
63 other: "over %{count} years"
64 almost_x_years:
64 almost_x_years:
65 one: "almost 1 year"
65 one: "almost 1 year"
66 other: "almost %{count} years"
66 other: "almost %{count} years"
67
67
68 number:
68 number:
69 format:
69 format:
70 separator: "."
70 separator: "."
71 delimiter: ""
71 delimiter: ""
72 precision: 3
72 precision: 3
73
73
74 human:
74 human:
75 format:
75 format:
76 delimiter: ""
76 delimiter: ""
77 precision: 1
77 precision: 1
78 storage_units:
78 storage_units:
79 format: "%n %u"
79 format: "%n %u"
80 units:
80 units:
81 byte:
81 byte:
82 one: "Byte"
82 one: "Byte"
83 other: "Bytes"
83 other: "Bytes"
84 kb: "kB"
84 kb: "kB"
85 mb: "MB"
85 mb: "MB"
86 gb: "GB"
86 gb: "GB"
87 tb: "TB"
87 tb: "TB"
88
88
89
89
90 # Used in array.to_sentence.
90 # Used in array.to_sentence.
91 support:
91 support:
92 array:
92 array:
93 sentence_connector: "and"
93 sentence_connector: "and"
94 skip_last_comma: false
94 skip_last_comma: false
95
95
96 activerecord:
96 activerecord:
97 errors:
97 errors:
98 template:
98 template:
99 header:
99 header:
100 one: "1 error prohibited this %{model} from being saved"
100 one: "1 error prohibited this %{model} from being saved"
101 other: "%{count} errors prohibited this %{model} from being saved"
101 other: "%{count} errors prohibited this %{model} from being saved"
102 messages:
102 messages:
103 inclusion: "is not included in the list"
103 inclusion: "is not included in the list"
104 exclusion: "is reserved"
104 exclusion: "is reserved"
105 invalid: "is invalid"
105 invalid: "is invalid"
106 confirmation: "doesn't match confirmation"
106 confirmation: "doesn't match confirmation"
107 accepted: "must be accepted"
107 accepted: "must be accepted"
108 empty: "can't be empty"
108 empty: "can't be empty"
109 blank: "can't be blank"
109 blank: "can't be blank"
110 too_long: "is too long (maximum is %{count} characters)"
110 too_long: "is too long (maximum is %{count} characters)"
111 too_short: "is too short (minimum is %{count} characters)"
111 too_short: "is too short (minimum is %{count} characters)"
112 wrong_length: "is the wrong length (should be %{count} characters)"
112 wrong_length: "is the wrong length (should be %{count} characters)"
113 taken: "has already been taken"
113 taken: "has already been taken"
114 not_a_number: "is not a number"
114 not_a_number: "is not a number"
115 not_a_date: "is not a valid date"
115 not_a_date: "is not a valid date"
116 greater_than: "must be greater than %{count}"
116 greater_than: "must be greater than %{count}"
117 greater_than_or_equal_to: "must be greater than or equal to %{count}"
117 greater_than_or_equal_to: "must be greater than or equal to %{count}"
118 equal_to: "must be equal to %{count}"
118 equal_to: "must be equal to %{count}"
119 less_than: "must be less than %{count}"
119 less_than: "must be less than %{count}"
120 less_than_or_equal_to: "must be less than or equal to %{count}"
120 less_than_or_equal_to: "must be less than or equal to %{count}"
121 odd: "must be odd"
121 odd: "must be odd"
122 even: "must be even"
122 even: "must be even"
123 greater_than_start_date: "must be greater than start date"
123 greater_than_start_date: "must be greater than start date"
124 not_same_project: "doesn't belong to the same project"
124 not_same_project: "doesn't belong to the same project"
125 circular_dependency: "This relation would create a circular dependency"
125 circular_dependency: "This relation would create a circular dependency"
126 cant_link_an_issue_with_a_descendant: "An issue cannot be linked to one of its subtasks"
126 cant_link_an_issue_with_a_descendant: "An issue cannot be linked to one of its subtasks"
127
127
128 actionview_instancetag_blank_option: Please select
128 actionview_instancetag_blank_option: Please select
129
129
130 general_text_No: 'No'
130 general_text_No: 'No'
131 general_text_Yes: 'Yes'
131 general_text_Yes: 'Yes'
132 general_text_no: 'no'
132 general_text_no: 'no'
133 general_text_yes: 'yes'
133 general_text_yes: 'yes'
134 general_lang_name: 'English'
134 general_lang_name: 'English'
135 general_csv_separator: ','
135 general_csv_separator: ','
136 general_csv_decimal_separator: '.'
136 general_csv_decimal_separator: '.'
137 general_csv_encoding: ISO-8859-1
137 general_csv_encoding: ISO-8859-1
138 general_pdf_encoding: UTF-8
138 general_pdf_encoding: UTF-8
139 general_first_day_of_week: '7'
139 general_first_day_of_week: '7'
140
140
141 notice_account_updated: Account was successfully updated.
141 notice_account_updated: Account was successfully updated.
142 notice_account_invalid_creditentials: Invalid user or password
142 notice_account_invalid_creditentials: Invalid user or password
143 notice_account_password_updated: Password was successfully updated.
143 notice_account_password_updated: Password was successfully updated.
144 notice_account_wrong_password: Wrong password
144 notice_account_wrong_password: Wrong password
145 notice_account_register_done: Account was successfully created. To activate your account, click on the link that was emailed to you.
145 notice_account_register_done: Account was successfully created. To activate your account, click on the link that was emailed to you.
146 notice_account_unknown_email: Unknown user.
146 notice_account_unknown_email: Unknown user.
147 notice_can_t_change_password: This account uses an external authentication source. Impossible to change the password.
147 notice_can_t_change_password: This account uses an external authentication source. Impossible to change the password.
148 notice_account_lost_email_sent: An email with instructions to choose a new password has been sent to you.
148 notice_account_lost_email_sent: An email with instructions to choose a new password has been sent to you.
149 notice_account_activated: Your account has been activated. You can now log in.
149 notice_account_activated: Your account has been activated. You can now log in.
150 notice_successful_create: Successful creation.
150 notice_successful_create: Successful creation.
151 notice_successful_update: Successful update.
151 notice_successful_update: Successful update.
152 notice_successful_delete: Successful deletion.
152 notice_successful_delete: Successful deletion.
153 notice_successful_connection: Successful connection.
153 notice_successful_connection: Successful connection.
154 notice_file_not_found: The page you were trying to access doesn't exist or has been removed.
154 notice_file_not_found: The page you were trying to access doesn't exist or has been removed.
155 notice_locking_conflict: Data has been updated by another user.
155 notice_locking_conflict: Data has been updated by another user.
156 notice_not_authorized: You are not authorized to access this page.
156 notice_not_authorized: You are not authorized to access this page.
157 notice_not_authorized_archived_project: The project you're trying to access has been archived.
157 notice_not_authorized_archived_project: The project you're trying to access has been archived.
158 notice_email_sent: "An email was sent to %{value}"
158 notice_email_sent: "An email was sent to %{value}"
159 notice_email_error: "An error occurred while sending mail (%{value})"
159 notice_email_error: "An error occurred while sending mail (%{value})"
160 notice_feeds_access_key_reseted: Your RSS access key was reset.
160 notice_feeds_access_key_reseted: Your RSS access key was reset.
161 notice_api_access_key_reseted: Your API access key was reset.
161 notice_api_access_key_reseted: Your API access key was reset.
162 notice_failed_to_save_issues: "Failed to save %{count} issue(s) on %{total} selected: %{ids}."
162 notice_failed_to_save_issues: "Failed to save %{count} issue(s) on %{total} selected: %{ids}."
163 notice_failed_to_save_members: "Failed to save member(s): %{errors}."
163 notice_failed_to_save_members: "Failed to save member(s): %{errors}."
164 notice_no_issue_selected: "No issue is selected! Please, check the issues you want to edit."
164 notice_no_issue_selected: "No issue is selected! Please, check the issues you want to edit."
165 notice_account_pending: "Your account was created and is now pending administrator approval."
165 notice_account_pending: "Your account was created and is now pending administrator approval."
166 notice_default_data_loaded: Default configuration successfully loaded.
166 notice_default_data_loaded: Default configuration successfully loaded.
167 notice_unable_delete_version: Unable to delete version.
167 notice_unable_delete_version: Unable to delete version.
168 notice_unable_delete_time_entry: Unable to delete time log entry.
168 notice_unable_delete_time_entry: Unable to delete time log entry.
169 notice_issue_done_ratios_updated: Issue done ratios updated.
169 notice_issue_done_ratios_updated: Issue done ratios updated.
170 notice_gantt_chart_truncated: "The chart was truncated because it exceeds the maximum number of items that can be displayed (%{max})"
170 notice_gantt_chart_truncated: "The chart was truncated because it exceeds the maximum number of items that can be displayed (%{max})"
171
171
172 error_can_t_load_default_data: "Default configuration could not be loaded: %{value}"
172 error_can_t_load_default_data: "Default configuration could not be loaded: %{value}"
173 error_scm_not_found: "The entry or revision was not found in the repository."
173 error_scm_not_found: "The entry or revision was not found in the repository."
174 error_scm_command_failed: "An error occurred when trying to access the repository: %{value}"
174 error_scm_command_failed: "An error occurred when trying to access the repository: %{value}"
175 error_scm_annotate: "The entry does not exist or cannot be annotated."
175 error_scm_annotate: "The entry does not exist or cannot be annotated."
176 error_issue_not_found_in_project: 'The issue was not found or does not belong to this project'
176 error_issue_not_found_in_project: 'The issue was not found or does not belong to this project'
177 error_no_tracker_in_project: 'No tracker is associated to this project. Please check the Project settings.'
177 error_no_tracker_in_project: 'No tracker is associated to this project. Please check the Project settings.'
178 error_no_default_issue_status: 'No default issue status is defined. Please check your configuration (Go to "Administration -> Issue statuses").'
178 error_no_default_issue_status: 'No default issue status is defined. Please check your configuration (Go to "Administration -> Issue statuses").'
179 error_can_not_delete_custom_field: Unable to delete custom field
179 error_can_not_delete_custom_field: Unable to delete custom field
180 error_can_not_delete_tracker: "This tracker contains issues and cannot be deleted."
180 error_can_not_delete_tracker: "This tracker contains issues and cannot be deleted."
181 error_can_not_remove_role: "This role is in use and cannot be deleted."
181 error_can_not_remove_role: "This role is in use and cannot be deleted."
182 error_can_not_reopen_issue_on_closed_version: 'An issue assigned to a closed version cannot be reopened'
182 error_can_not_reopen_issue_on_closed_version: 'An issue assigned to a closed version cannot be reopened'
183 error_can_not_archive_project: This project cannot be archived
183 error_can_not_archive_project: This project cannot be archived
184 error_issue_done_ratios_not_updated: "Issue done ratios not updated."
184 error_issue_done_ratios_not_updated: "Issue done ratios not updated."
185 error_workflow_copy_source: 'Please select a source tracker or role'
185 error_workflow_copy_source: 'Please select a source tracker or role'
186 error_workflow_copy_target: 'Please select target tracker(s) and role(s)'
186 error_workflow_copy_target: 'Please select target tracker(s) and role(s)'
187 error_unable_delete_issue_status: 'Unable to delete issue status'
187 error_unable_delete_issue_status: 'Unable to delete issue status'
188 error_unable_to_connect: "Unable to connect (%{value})"
188 error_unable_to_connect: "Unable to connect (%{value})"
189 warning_attachments_not_saved: "%{count} file(s) could not be saved."
189 warning_attachments_not_saved: "%{count} file(s) could not be saved."
190
190
191 mail_subject_lost_password: "Your %{value} password"
191 mail_subject_lost_password: "Your %{value} password"
192 mail_body_lost_password: 'To change your password, click on the following link:'
192 mail_body_lost_password: 'To change your password, click on the following link:'
193 mail_subject_register: "Your %{value} account activation"
193 mail_subject_register: "Your %{value} account activation"
194 mail_body_register: 'To activate your account, click on the following link:'
194 mail_body_register: 'To activate your account, click on the following link:'
195 mail_body_account_information_external: "You can use your %{value} account to log in."
195 mail_body_account_information_external: "You can use your %{value} account to log in."
196 mail_body_account_information: Your account information
196 mail_body_account_information: Your account information
197 mail_subject_account_activation_request: "%{value} account activation request"
197 mail_subject_account_activation_request: "%{value} account activation request"
198 mail_body_account_activation_request: "A new user (%{value}) has registered. The account is pending your approval:"
198 mail_body_account_activation_request: "A new user (%{value}) has registered. The account is pending your approval:"
199 mail_subject_reminder: "%{count} issue(s) due in the next %{days} days"
199 mail_subject_reminder: "%{count} issue(s) due in the next %{days} days"
200 mail_body_reminder: "%{count} issue(s) that are assigned to you are due in the next %{days} days:"
200 mail_body_reminder: "%{count} issue(s) that are assigned to you are due in the next %{days} days:"
201 mail_subject_wiki_content_added: "'%{id}' wiki page has been added"
201 mail_subject_wiki_content_added: "'%{id}' wiki page has been added"
202 mail_body_wiki_content_added: "The '%{id}' wiki page has been added by %{author}."
202 mail_body_wiki_content_added: "The '%{id}' wiki page has been added by %{author}."
203 mail_subject_wiki_content_updated: "'%{id}' wiki page has been updated"
203 mail_subject_wiki_content_updated: "'%{id}' wiki page has been updated"
204 mail_body_wiki_content_updated: "The '%{id}' wiki page has been updated by %{author}."
204 mail_body_wiki_content_updated: "The '%{id}' wiki page has been updated by %{author}."
205
205
206 gui_validation_error: 1 error
206 gui_validation_error: 1 error
207 gui_validation_error_plural: "%{count} errors"
207 gui_validation_error_plural: "%{count} errors"
208
208
209 field_name: Name
209 field_name: Name
210 field_description: Description
210 field_description: Description
211 field_summary: Summary
211 field_summary: Summary
212 field_is_required: Required
212 field_is_required: Required
213 field_firstname: First name
213 field_firstname: First name
214 field_lastname: Last name
214 field_lastname: Last name
215 field_mail: Email
215 field_mail: Email
216 field_filename: File
216 field_filename: File
217 field_filesize: Size
217 field_filesize: Size
218 field_downloads: Downloads
218 field_downloads: Downloads
219 field_author: Author
219 field_author: Author
220 field_created_on: Created
220 field_created_on: Created
221 field_updated_on: Updated
221 field_updated_on: Updated
222 field_field_format: Format
222 field_field_format: Format
223 field_is_for_all: For all projects
223 field_is_for_all: For all projects
224 field_possible_values: Possible values
224 field_possible_values: Possible values
225 field_regexp: Regular expression
225 field_regexp: Regular expression
226 field_min_length: Minimum length
226 field_min_length: Minimum length
227 field_max_length: Maximum length
227 field_max_length: Maximum length
228 field_value: Value
228 field_value: Value
229 field_category: Category
229 field_category: Category
230 field_title: Title
230 field_title: Title
231 field_project: Project
231 field_project: Project
232 field_issue: Issue
232 field_issue: Issue
233 field_status: Status
233 field_status: Status
234 field_notes: Notes
234 field_notes: Notes
235 field_is_closed: Issue closed
235 field_is_closed: Issue closed
236 field_is_default: Default value
236 field_is_default: Default value
237 field_tracker: Tracker
237 field_tracker: Tracker
238 field_subject: Subject
238 field_subject: Subject
239 field_due_date: Due date
239 field_due_date: Due date
240 field_assigned_to: Assignee
240 field_assigned_to: Assignee
241 field_priority: Priority
241 field_priority: Priority
242 field_fixed_version: Target version
242 field_fixed_version: Target version
243 field_user: User
243 field_user: User
244 field_principal: Principal
244 field_principal: Principal
245 field_role: Role
245 field_role: Role
246 field_homepage: Homepage
246 field_homepage: Homepage
247 field_is_public: Public
247 field_is_public: Public
248 field_parent: Subproject of
248 field_parent: Subproject of
249 field_is_in_roadmap: Issues displayed in roadmap
249 field_is_in_roadmap: Issues displayed in roadmap
250 field_login: Login
250 field_login: Login
251 field_mail_notification: Email notifications
251 field_mail_notification: Email notifications
252 field_admin: Administrator
252 field_admin: Administrator
253 field_last_login_on: Last connection
253 field_last_login_on: Last connection
254 field_language: Language
254 field_language: Language
255 field_effective_date: Date
255 field_effective_date: Date
256 field_password: Password
256 field_password: Password
257 field_new_password: New password
257 field_new_password: New password
258 field_password_confirmation: Confirmation
258 field_password_confirmation: Confirmation
259 field_version: Version
259 field_version: Version
260 field_type: Type
260 field_type: Type
261 field_host: Host
261 field_host: Host
262 field_port: Port
262 field_port: Port
263 field_account: Account
263 field_account: Account
264 field_base_dn: Base DN
264 field_base_dn: Base DN
265 field_attr_login: Login attribute
265 field_attr_login: Login attribute
266 field_attr_firstname: Firstname attribute
266 field_attr_firstname: Firstname attribute
267 field_attr_lastname: Lastname attribute
267 field_attr_lastname: Lastname attribute
268 field_attr_mail: Email attribute
268 field_attr_mail: Email attribute
269 field_onthefly: On-the-fly user creation
269 field_onthefly: On-the-fly user creation
270 field_start_date: Start date
270 field_start_date: Start date
271 field_done_ratio: % Done
271 field_done_ratio: % Done
272 field_auth_source: Authentication mode
272 field_auth_source: Authentication mode
273 field_hide_mail: Hide my email address
273 field_hide_mail: Hide my email address
274 field_comments: Comment
274 field_comments: Comment
275 field_url: URL
275 field_url: URL
276 field_start_page: Start page
276 field_start_page: Start page
277 field_subproject: Subproject
277 field_subproject: Subproject
278 field_hours: Hours
278 field_hours: Hours
279 field_activity: Activity
279 field_activity: Activity
280 field_spent_on: Date
280 field_spent_on: Date
281 field_identifier: Identifier
281 field_identifier: Identifier
282 field_is_filter: Used as a filter
282 field_is_filter: Used as a filter
283 field_issue_to: Related issue
283 field_issue_to: Related issue
284 field_delay: Delay
284 field_delay: Delay
285 field_assignable: Issues can be assigned to this role
285 field_assignable: Issues can be assigned to this role
286 field_redirect_existing_links: Redirect existing links
286 field_redirect_existing_links: Redirect existing links
287 field_estimated_hours: Estimated time
287 field_estimated_hours: Estimated time
288 field_column_names: Columns
288 field_column_names: Columns
289 field_time_entries: Log time
289 field_time_entries: Log time
290 field_time_zone: Time zone
290 field_time_zone: Time zone
291 field_searchable: Searchable
291 field_searchable: Searchable
292 field_default_value: Default value
292 field_default_value: Default value
293 field_comments_sorting: Display comments
293 field_comments_sorting: Display comments
294 field_parent_title: Parent page
294 field_parent_title: Parent page
295 field_editable: Editable
295 field_editable: Editable
296 field_watcher: Watcher
296 field_watcher: Watcher
297 field_identity_url: OpenID URL
297 field_identity_url: OpenID URL
298 field_content: Content
298 field_content: Content
299 field_group_by: Group results by
299 field_group_by: Group results by
300 field_sharing: Sharing
300 field_sharing: Sharing
301 field_parent_issue: Parent task
301 field_parent_issue: Parent task
302 field_member_of_group: "Assignee's group"
302 field_member_of_group: "Assignee's group"
303 field_assigned_to_role: "Assignee's role"
303 field_assigned_to_role: "Assignee's role"
304 field_text: Text field
304 field_text: Text field
305 field_visible: Visible
305 field_visible: Visible
306 field_warn_on_leaving_unsaved: "Warn me when leaving a page with unsaved text"
306 field_warn_on_leaving_unsaved: "Warn me when leaving a page with unsaved text"
307 field_issues_visibility: Issues visibility
307
308
308 setting_app_title: Application title
309 setting_app_title: Application title
309 setting_app_subtitle: Application subtitle
310 setting_app_subtitle: Application subtitle
310 setting_welcome_text: Welcome text
311 setting_welcome_text: Welcome text
311 setting_default_language: Default language
312 setting_default_language: Default language
312 setting_login_required: Authentication required
313 setting_login_required: Authentication required
313 setting_self_registration: Self-registration
314 setting_self_registration: Self-registration
314 setting_attachment_max_size: Attachment max. size
315 setting_attachment_max_size: Attachment max. size
315 setting_issues_export_limit: Issues export limit
316 setting_issues_export_limit: Issues export limit
316 setting_mail_from: Emission email address
317 setting_mail_from: Emission email address
317 setting_bcc_recipients: Blind carbon copy recipients (bcc)
318 setting_bcc_recipients: Blind carbon copy recipients (bcc)
318 setting_plain_text_mail: Plain text mail (no HTML)
319 setting_plain_text_mail: Plain text mail (no HTML)
319 setting_host_name: Host name and path
320 setting_host_name: Host name and path
320 setting_text_formatting: Text formatting
321 setting_text_formatting: Text formatting
321 setting_wiki_compression: Wiki history compression
322 setting_wiki_compression: Wiki history compression
322 setting_feeds_limit: Feed content limit
323 setting_feeds_limit: Feed content limit
323 setting_default_projects_public: New projects are public by default
324 setting_default_projects_public: New projects are public by default
324 setting_autofetch_changesets: Autofetch commits
325 setting_autofetch_changesets: Autofetch commits
325 setting_sys_api_enabled: Enable WS for repository management
326 setting_sys_api_enabled: Enable WS for repository management
326 setting_commit_ref_keywords: Referencing keywords
327 setting_commit_ref_keywords: Referencing keywords
327 setting_commit_fix_keywords: Fixing keywords
328 setting_commit_fix_keywords: Fixing keywords
328 setting_autologin: Autologin
329 setting_autologin: Autologin
329 setting_date_format: Date format
330 setting_date_format: Date format
330 setting_time_format: Time format
331 setting_time_format: Time format
331 setting_cross_project_issue_relations: Allow cross-project issue relations
332 setting_cross_project_issue_relations: Allow cross-project issue relations
332 setting_issue_list_default_columns: Default columns displayed on the issue list
333 setting_issue_list_default_columns: Default columns displayed on the issue list
333 setting_repositories_encodings: Repositories encodings
334 setting_repositories_encodings: Repositories encodings
334 setting_commit_logs_encoding: Commit messages encoding
335 setting_commit_logs_encoding: Commit messages encoding
335 setting_emails_header: Emails header
336 setting_emails_header: Emails header
336 setting_emails_footer: Emails footer
337 setting_emails_footer: Emails footer
337 setting_protocol: Protocol
338 setting_protocol: Protocol
338 setting_per_page_options: Objects per page options
339 setting_per_page_options: Objects per page options
339 setting_user_format: Users display format
340 setting_user_format: Users display format
340 setting_activity_days_default: Days displayed on project activity
341 setting_activity_days_default: Days displayed on project activity
341 setting_display_subprojects_issues: Display subprojects issues on main projects by default
342 setting_display_subprojects_issues: Display subprojects issues on main projects by default
342 setting_enabled_scm: Enabled SCM
343 setting_enabled_scm: Enabled SCM
343 setting_mail_handler_body_delimiters: "Truncate emails after one of these lines"
344 setting_mail_handler_body_delimiters: "Truncate emails after one of these lines"
344 setting_mail_handler_api_enabled: Enable WS for incoming emails
345 setting_mail_handler_api_enabled: Enable WS for incoming emails
345 setting_mail_handler_api_key: API key
346 setting_mail_handler_api_key: API key
346 setting_sequential_project_identifiers: Generate sequential project identifiers
347 setting_sequential_project_identifiers: Generate sequential project identifiers
347 setting_gravatar_enabled: Use Gravatar user icons
348 setting_gravatar_enabled: Use Gravatar user icons
348 setting_gravatar_default: Default Gravatar image
349 setting_gravatar_default: Default Gravatar image
349 setting_diff_max_lines_displayed: Max number of diff lines displayed
350 setting_diff_max_lines_displayed: Max number of diff lines displayed
350 setting_file_max_size_displayed: Max size of text files displayed inline
351 setting_file_max_size_displayed: Max size of text files displayed inline
351 setting_repository_log_display_limit: Maximum number of revisions displayed on file log
352 setting_repository_log_display_limit: Maximum number of revisions displayed on file log
352 setting_openid: Allow OpenID login and registration
353 setting_openid: Allow OpenID login and registration
353 setting_password_min_length: Minimum password length
354 setting_password_min_length: Minimum password length
354 setting_new_project_user_role_id: Role given to a non-admin user who creates a project
355 setting_new_project_user_role_id: Role given to a non-admin user who creates a project
355 setting_default_projects_modules: Default enabled modules for new projects
356 setting_default_projects_modules: Default enabled modules for new projects
356 setting_issue_done_ratio: Calculate the issue done ratio with
357 setting_issue_done_ratio: Calculate the issue done ratio with
357 setting_issue_done_ratio_issue_field: Use the issue field
358 setting_issue_done_ratio_issue_field: Use the issue field
358 setting_issue_done_ratio_issue_status: Use the issue status
359 setting_issue_done_ratio_issue_status: Use the issue status
359 setting_start_of_week: Start calendars on
360 setting_start_of_week: Start calendars on
360 setting_rest_api_enabled: Enable REST web service
361 setting_rest_api_enabled: Enable REST web service
361 setting_cache_formatted_text: Cache formatted text
362 setting_cache_formatted_text: Cache formatted text
362 setting_default_notification_option: Default notification option
363 setting_default_notification_option: Default notification option
363 setting_commit_logtime_enabled: Enable time logging
364 setting_commit_logtime_enabled: Enable time logging
364 setting_commit_logtime_activity_id: Activity for logged time
365 setting_commit_logtime_activity_id: Activity for logged time
365 setting_gantt_items_limit: Maximum number of items displayed on the gantt chart
366 setting_gantt_items_limit: Maximum number of items displayed on the gantt chart
366
367
367 permission_add_project: Create project
368 permission_add_project: Create project
368 permission_add_subprojects: Create subprojects
369 permission_add_subprojects: Create subprojects
369 permission_edit_project: Edit project
370 permission_edit_project: Edit project
370 permission_select_project_modules: Select project modules
371 permission_select_project_modules: Select project modules
371 permission_manage_members: Manage members
372 permission_manage_members: Manage members
372 permission_manage_project_activities: Manage project activities
373 permission_manage_project_activities: Manage project activities
373 permission_manage_versions: Manage versions
374 permission_manage_versions: Manage versions
374 permission_manage_categories: Manage issue categories
375 permission_manage_categories: Manage issue categories
375 permission_view_issues: View Issues
376 permission_view_issues: View Issues
376 permission_add_issues: Add issues
377 permission_add_issues: Add issues
377 permission_edit_issues: Edit issues
378 permission_edit_issues: Edit issues
378 permission_manage_issue_relations: Manage issue relations
379 permission_manage_issue_relations: Manage issue relations
379 permission_add_issue_notes: Add notes
380 permission_add_issue_notes: Add notes
380 permission_edit_issue_notes: Edit notes
381 permission_edit_issue_notes: Edit notes
381 permission_edit_own_issue_notes: Edit own notes
382 permission_edit_own_issue_notes: Edit own notes
382 permission_move_issues: Move issues
383 permission_move_issues: Move issues
383 permission_delete_issues: Delete issues
384 permission_delete_issues: Delete issues
384 permission_manage_public_queries: Manage public queries
385 permission_manage_public_queries: Manage public queries
385 permission_save_queries: Save queries
386 permission_save_queries: Save queries
386 permission_view_gantt: View gantt chart
387 permission_view_gantt: View gantt chart
387 permission_view_calendar: View calendar
388 permission_view_calendar: View calendar
388 permission_view_issue_watchers: View watchers list
389 permission_view_issue_watchers: View watchers list
389 permission_add_issue_watchers: Add watchers
390 permission_add_issue_watchers: Add watchers
390 permission_delete_issue_watchers: Delete watchers
391 permission_delete_issue_watchers: Delete watchers
391 permission_log_time: Log spent time
392 permission_log_time: Log spent time
392 permission_view_time_entries: View spent time
393 permission_view_time_entries: View spent time
393 permission_edit_time_entries: Edit time logs
394 permission_edit_time_entries: Edit time logs
394 permission_edit_own_time_entries: Edit own time logs
395 permission_edit_own_time_entries: Edit own time logs
395 permission_manage_news: Manage news
396 permission_manage_news: Manage news
396 permission_comment_news: Comment news
397 permission_comment_news: Comment news
397 permission_manage_documents: Manage documents
398 permission_manage_documents: Manage documents
398 permission_view_documents: View documents
399 permission_view_documents: View documents
399 permission_manage_files: Manage files
400 permission_manage_files: Manage files
400 permission_view_files: View files
401 permission_view_files: View files
401 permission_manage_wiki: Manage wiki
402 permission_manage_wiki: Manage wiki
402 permission_rename_wiki_pages: Rename wiki pages
403 permission_rename_wiki_pages: Rename wiki pages
403 permission_delete_wiki_pages: Delete wiki pages
404 permission_delete_wiki_pages: Delete wiki pages
404 permission_view_wiki_pages: View wiki
405 permission_view_wiki_pages: View wiki
405 permission_view_wiki_edits: View wiki history
406 permission_view_wiki_edits: View wiki history
406 permission_edit_wiki_pages: Edit wiki pages
407 permission_edit_wiki_pages: Edit wiki pages
407 permission_delete_wiki_pages_attachments: Delete attachments
408 permission_delete_wiki_pages_attachments: Delete attachments
408 permission_protect_wiki_pages: Protect wiki pages
409 permission_protect_wiki_pages: Protect wiki pages
409 permission_manage_repository: Manage repository
410 permission_manage_repository: Manage repository
410 permission_browse_repository: Browse repository
411 permission_browse_repository: Browse repository
411 permission_view_changesets: View changesets
412 permission_view_changesets: View changesets
412 permission_commit_access: Commit access
413 permission_commit_access: Commit access
413 permission_manage_boards: Manage forums
414 permission_manage_boards: Manage forums
414 permission_view_messages: View messages
415 permission_view_messages: View messages
415 permission_add_messages: Post messages
416 permission_add_messages: Post messages
416 permission_edit_messages: Edit messages
417 permission_edit_messages: Edit messages
417 permission_edit_own_messages: Edit own messages
418 permission_edit_own_messages: Edit own messages
418 permission_delete_messages: Delete messages
419 permission_delete_messages: Delete messages
419 permission_delete_own_messages: Delete own messages
420 permission_delete_own_messages: Delete own messages
420 permission_export_wiki_pages: Export wiki pages
421 permission_export_wiki_pages: Export wiki pages
421 permission_manage_subtasks: Manage subtasks
422 permission_manage_subtasks: Manage subtasks
422
423
423 project_module_issue_tracking: Issue tracking
424 project_module_issue_tracking: Issue tracking
424 project_module_time_tracking: Time tracking
425 project_module_time_tracking: Time tracking
425 project_module_news: News
426 project_module_news: News
426 project_module_documents: Documents
427 project_module_documents: Documents
427 project_module_files: Files
428 project_module_files: Files
428 project_module_wiki: Wiki
429 project_module_wiki: Wiki
429 project_module_repository: Repository
430 project_module_repository: Repository
430 project_module_boards: Forums
431 project_module_boards: Forums
431 project_module_calendar: Calendar
432 project_module_calendar: Calendar
432 project_module_gantt: Gantt
433 project_module_gantt: Gantt
433
434
434 label_user: User
435 label_user: User
435 label_user_plural: Users
436 label_user_plural: Users
436 label_user_new: New user
437 label_user_new: New user
437 label_user_anonymous: Anonymous
438 label_user_anonymous: Anonymous
438 label_project: Project
439 label_project: Project
439 label_project_new: New project
440 label_project_new: New project
440 label_project_plural: Projects
441 label_project_plural: Projects
441 label_x_projects:
442 label_x_projects:
442 zero: no projects
443 zero: no projects
443 one: 1 project
444 one: 1 project
444 other: "%{count} projects"
445 other: "%{count} projects"
445 label_project_all: All Projects
446 label_project_all: All Projects
446 label_project_latest: Latest projects
447 label_project_latest: Latest projects
447 label_issue: Issue
448 label_issue: Issue
448 label_issue_new: New issue
449 label_issue_new: New issue
449 label_issue_plural: Issues
450 label_issue_plural: Issues
450 label_issue_view_all: View all issues
451 label_issue_view_all: View all issues
451 label_issues_by: "Issues by %{value}"
452 label_issues_by: "Issues by %{value}"
452 label_issue_added: Issue added
453 label_issue_added: Issue added
453 label_issue_updated: Issue updated
454 label_issue_updated: Issue updated
454 label_issue_note_added: Note added
455 label_issue_note_added: Note added
455 label_issue_status_updated: Status updated
456 label_issue_status_updated: Status updated
456 label_issue_priority_updated: Priority updated
457 label_issue_priority_updated: Priority updated
457 label_document: Document
458 label_document: Document
458 label_document_new: New document
459 label_document_new: New document
459 label_document_plural: Documents
460 label_document_plural: Documents
460 label_document_added: Document added
461 label_document_added: Document added
461 label_role: Role
462 label_role: Role
462 label_role_plural: Roles
463 label_role_plural: Roles
463 label_role_new: New role
464 label_role_new: New role
464 label_role_and_permissions: Roles and permissions
465 label_role_and_permissions: Roles and permissions
465 label_role_anonymous: Anonymous
466 label_role_anonymous: Anonymous
466 label_role_non_member: Non member
467 label_role_non_member: Non member
467 label_member: Member
468 label_member: Member
468 label_member_new: New member
469 label_member_new: New member
469 label_member_plural: Members
470 label_member_plural: Members
470 label_tracker: Tracker
471 label_tracker: Tracker
471 label_tracker_plural: Trackers
472 label_tracker_plural: Trackers
472 label_tracker_new: New tracker
473 label_tracker_new: New tracker
473 label_workflow: Workflow
474 label_workflow: Workflow
474 label_issue_status: Issue status
475 label_issue_status: Issue status
475 label_issue_status_plural: Issue statuses
476 label_issue_status_plural: Issue statuses
476 label_issue_status_new: New status
477 label_issue_status_new: New status
477 label_issue_category: Issue category
478 label_issue_category: Issue category
478 label_issue_category_plural: Issue categories
479 label_issue_category_plural: Issue categories
479 label_issue_category_new: New category
480 label_issue_category_new: New category
480 label_custom_field: Custom field
481 label_custom_field: Custom field
481 label_custom_field_plural: Custom fields
482 label_custom_field_plural: Custom fields
482 label_custom_field_new: New custom field
483 label_custom_field_new: New custom field
483 label_enumerations: Enumerations
484 label_enumerations: Enumerations
484 label_enumeration_new: New value
485 label_enumeration_new: New value
485 label_information: Information
486 label_information: Information
486 label_information_plural: Information
487 label_information_plural: Information
487 label_please_login: Please log in
488 label_please_login: Please log in
488 label_register: Register
489 label_register: Register
489 label_login_with_open_id_option: or login with OpenID
490 label_login_with_open_id_option: or login with OpenID
490 label_password_lost: Lost password
491 label_password_lost: Lost password
491 label_home: Home
492 label_home: Home
492 label_my_page: My page
493 label_my_page: My page
493 label_my_account: My account
494 label_my_account: My account
494 label_my_projects: My projects
495 label_my_projects: My projects
495 label_my_page_block: My page block
496 label_my_page_block: My page block
496 label_administration: Administration
497 label_administration: Administration
497 label_login: Sign in
498 label_login: Sign in
498 label_logout: Sign out
499 label_logout: Sign out
499 label_help: Help
500 label_help: Help
500 label_reported_issues: Reported issues
501 label_reported_issues: Reported issues
501 label_assigned_to_me_issues: Issues assigned to me
502 label_assigned_to_me_issues: Issues assigned to me
502 label_last_login: Last connection
503 label_last_login: Last connection
503 label_registered_on: Registered on
504 label_registered_on: Registered on
504 label_activity: Activity
505 label_activity: Activity
505 label_overall_activity: Overall activity
506 label_overall_activity: Overall activity
506 label_user_activity: "%{value}'s activity"
507 label_user_activity: "%{value}'s activity"
507 label_new: New
508 label_new: New
508 label_logged_as: Logged in as
509 label_logged_as: Logged in as
509 label_environment: Environment
510 label_environment: Environment
510 label_authentication: Authentication
511 label_authentication: Authentication
511 label_auth_source: Authentication mode
512 label_auth_source: Authentication mode
512 label_auth_source_new: New authentication mode
513 label_auth_source_new: New authentication mode
513 label_auth_source_plural: Authentication modes
514 label_auth_source_plural: Authentication modes
514 label_subproject_plural: Subprojects
515 label_subproject_plural: Subprojects
515 label_subproject_new: New subproject
516 label_subproject_new: New subproject
516 label_and_its_subprojects: "%{value} and its subprojects"
517 label_and_its_subprojects: "%{value} and its subprojects"
517 label_min_max_length: Min - Max length
518 label_min_max_length: Min - Max length
518 label_list: List
519 label_list: List
519 label_date: Date
520 label_date: Date
520 label_integer: Integer
521 label_integer: Integer
521 label_float: Float
522 label_float: Float
522 label_boolean: Boolean
523 label_boolean: Boolean
523 label_string: Text
524 label_string: Text
524 label_text: Long text
525 label_text: Long text
525 label_attribute: Attribute
526 label_attribute: Attribute
526 label_attribute_plural: Attributes
527 label_attribute_plural: Attributes
527 label_download: "%{count} Download"
528 label_download: "%{count} Download"
528 label_download_plural: "%{count} Downloads"
529 label_download_plural: "%{count} Downloads"
529 label_no_data: No data to display
530 label_no_data: No data to display
530 label_change_status: Change status
531 label_change_status: Change status
531 label_history: History
532 label_history: History
532 label_attachment: File
533 label_attachment: File
533 label_attachment_new: New file
534 label_attachment_new: New file
534 label_attachment_delete: Delete file
535 label_attachment_delete: Delete file
535 label_attachment_plural: Files
536 label_attachment_plural: Files
536 label_file_added: File added
537 label_file_added: File added
537 label_report: Report
538 label_report: Report
538 label_report_plural: Reports
539 label_report_plural: Reports
539 label_news: News
540 label_news: News
540 label_news_new: Add news
541 label_news_new: Add news
541 label_news_plural: News
542 label_news_plural: News
542 label_news_latest: Latest news
543 label_news_latest: Latest news
543 label_news_view_all: View all news
544 label_news_view_all: View all news
544 label_news_added: News added
545 label_news_added: News added
545 label_news_comment_added: Comment added to a news
546 label_news_comment_added: Comment added to a news
546 label_settings: Settings
547 label_settings: Settings
547 label_overview: Overview
548 label_overview: Overview
548 label_version: Version
549 label_version: Version
549 label_version_new: New version
550 label_version_new: New version
550 label_version_plural: Versions
551 label_version_plural: Versions
551 label_close_versions: Close completed versions
552 label_close_versions: Close completed versions
552 label_confirmation: Confirmation
553 label_confirmation: Confirmation
553 label_export_to: 'Also available in:'
554 label_export_to: 'Also available in:'
554 label_read: Read...
555 label_read: Read...
555 label_public_projects: Public projects
556 label_public_projects: Public projects
556 label_open_issues: open
557 label_open_issues: open
557 label_open_issues_plural: open
558 label_open_issues_plural: open
558 label_closed_issues: closed
559 label_closed_issues: closed
559 label_closed_issues_plural: closed
560 label_closed_issues_plural: closed
560 label_x_open_issues_abbr_on_total:
561 label_x_open_issues_abbr_on_total:
561 zero: 0 open / %{total}
562 zero: 0 open / %{total}
562 one: 1 open / %{total}
563 one: 1 open / %{total}
563 other: "%{count} open / %{total}"
564 other: "%{count} open / %{total}"
564 label_x_open_issues_abbr:
565 label_x_open_issues_abbr:
565 zero: 0 open
566 zero: 0 open
566 one: 1 open
567 one: 1 open
567 other: "%{count} open"
568 other: "%{count} open"
568 label_x_closed_issues_abbr:
569 label_x_closed_issues_abbr:
569 zero: 0 closed
570 zero: 0 closed
570 one: 1 closed
571 one: 1 closed
571 other: "%{count} closed"
572 other: "%{count} closed"
572 label_total: Total
573 label_total: Total
573 label_permissions: Permissions
574 label_permissions: Permissions
574 label_current_status: Current status
575 label_current_status: Current status
575 label_new_statuses_allowed: New statuses allowed
576 label_new_statuses_allowed: New statuses allowed
576 label_all: all
577 label_all: all
577 label_none: none
578 label_none: none
578 label_nobody: nobody
579 label_nobody: nobody
579 label_next: Next
580 label_next: Next
580 label_previous: Previous
581 label_previous: Previous
581 label_used_by: Used by
582 label_used_by: Used by
582 label_details: Details
583 label_details: Details
583 label_add_note: Add a note
584 label_add_note: Add a note
584 label_per_page: Per page
585 label_per_page: Per page
585 label_calendar: Calendar
586 label_calendar: Calendar
586 label_months_from: months from
587 label_months_from: months from
587 label_gantt: Gantt
588 label_gantt: Gantt
588 label_internal: Internal
589 label_internal: Internal
589 label_last_changes: "last %{count} changes"
590 label_last_changes: "last %{count} changes"
590 label_change_view_all: View all changes
591 label_change_view_all: View all changes
591 label_personalize_page: Personalize this page
592 label_personalize_page: Personalize this page
592 label_comment: Comment
593 label_comment: Comment
593 label_comment_plural: Comments
594 label_comment_plural: Comments
594 label_x_comments:
595 label_x_comments:
595 zero: no comments
596 zero: no comments
596 one: 1 comment
597 one: 1 comment
597 other: "%{count} comments"
598 other: "%{count} comments"
598 label_comment_add: Add a comment
599 label_comment_add: Add a comment
599 label_comment_added: Comment added
600 label_comment_added: Comment added
600 label_comment_delete: Delete comments
601 label_comment_delete: Delete comments
601 label_query: Custom query
602 label_query: Custom query
602 label_query_plural: Custom queries
603 label_query_plural: Custom queries
603 label_query_new: New query
604 label_query_new: New query
604 label_my_queries: My custom queries
605 label_my_queries: My custom queries
605 label_filter_add: Add filter
606 label_filter_add: Add filter
606 label_filter_plural: Filters
607 label_filter_plural: Filters
607 label_equals: is
608 label_equals: is
608 label_not_equals: is not
609 label_not_equals: is not
609 label_in_less_than: in less than
610 label_in_less_than: in less than
610 label_in_more_than: in more than
611 label_in_more_than: in more than
611 label_greater_or_equal: '>='
612 label_greater_or_equal: '>='
612 label_less_or_equal: '<='
613 label_less_or_equal: '<='
613 label_in: in
614 label_in: in
614 label_today: today
615 label_today: today
615 label_all_time: all time
616 label_all_time: all time
616 label_yesterday: yesterday
617 label_yesterday: yesterday
617 label_this_week: this week
618 label_this_week: this week
618 label_last_week: last week
619 label_last_week: last week
619 label_last_n_days: "last %{count} days"
620 label_last_n_days: "last %{count} days"
620 label_this_month: this month
621 label_this_month: this month
621 label_last_month: last month
622 label_last_month: last month
622 label_this_year: this year
623 label_this_year: this year
623 label_date_range: Date range
624 label_date_range: Date range
624 label_less_than_ago: less than days ago
625 label_less_than_ago: less than days ago
625 label_more_than_ago: more than days ago
626 label_more_than_ago: more than days ago
626 label_ago: days ago
627 label_ago: days ago
627 label_contains: contains
628 label_contains: contains
628 label_not_contains: doesn't contain
629 label_not_contains: doesn't contain
629 label_day_plural: days
630 label_day_plural: days
630 label_repository: Repository
631 label_repository: Repository
631 label_repository_plural: Repositories
632 label_repository_plural: Repositories
632 label_browse: Browse
633 label_browse: Browse
633 label_modification: "%{count} change"
634 label_modification: "%{count} change"
634 label_modification_plural: "%{count} changes"
635 label_modification_plural: "%{count} changes"
635 label_branch: Branch
636 label_branch: Branch
636 label_tag: Tag
637 label_tag: Tag
637 label_revision: Revision
638 label_revision: Revision
638 label_revision_plural: Revisions
639 label_revision_plural: Revisions
639 label_revision_id: "Revision %{value}"
640 label_revision_id: "Revision %{value}"
640 label_associated_revisions: Associated revisions
641 label_associated_revisions: Associated revisions
641 label_added: added
642 label_added: added
642 label_modified: modified
643 label_modified: modified
643 label_copied: copied
644 label_copied: copied
644 label_renamed: renamed
645 label_renamed: renamed
645 label_deleted: deleted
646 label_deleted: deleted
646 label_latest_revision: Latest revision
647 label_latest_revision: Latest revision
647 label_latest_revision_plural: Latest revisions
648 label_latest_revision_plural: Latest revisions
648 label_view_revisions: View revisions
649 label_view_revisions: View revisions
649 label_view_all_revisions: View all revisions
650 label_view_all_revisions: View all revisions
650 label_max_size: Maximum size
651 label_max_size: Maximum size
651 label_sort_highest: Move to top
652 label_sort_highest: Move to top
652 label_sort_higher: Move up
653 label_sort_higher: Move up
653 label_sort_lower: Move down
654 label_sort_lower: Move down
654 label_sort_lowest: Move to bottom
655 label_sort_lowest: Move to bottom
655 label_roadmap: Roadmap
656 label_roadmap: Roadmap
656 label_roadmap_due_in: "Due in %{value}"
657 label_roadmap_due_in: "Due in %{value}"
657 label_roadmap_overdue: "%{value} late"
658 label_roadmap_overdue: "%{value} late"
658 label_roadmap_no_issues: No issues for this version
659 label_roadmap_no_issues: No issues for this version
659 label_search: Search
660 label_search: Search
660 label_result_plural: Results
661 label_result_plural: Results
661 label_all_words: All words
662 label_all_words: All words
662 label_wiki: Wiki
663 label_wiki: Wiki
663 label_wiki_edit: Wiki edit
664 label_wiki_edit: Wiki edit
664 label_wiki_edit_plural: Wiki edits
665 label_wiki_edit_plural: Wiki edits
665 label_wiki_page: Wiki page
666 label_wiki_page: Wiki page
666 label_wiki_page_plural: Wiki pages
667 label_wiki_page_plural: Wiki pages
667 label_index_by_title: Index by title
668 label_index_by_title: Index by title
668 label_index_by_date: Index by date
669 label_index_by_date: Index by date
669 label_current_version: Current version
670 label_current_version: Current version
670 label_preview: Preview
671 label_preview: Preview
671 label_feed_plural: Feeds
672 label_feed_plural: Feeds
672 label_changes_details: Details of all changes
673 label_changes_details: Details of all changes
673 label_issue_tracking: Issue tracking
674 label_issue_tracking: Issue tracking
674 label_spent_time: Spent time
675 label_spent_time: Spent time
675 label_overall_spent_time: Overall spent time
676 label_overall_spent_time: Overall spent time
676 label_f_hour: "%{value} hour"
677 label_f_hour: "%{value} hour"
677 label_f_hour_plural: "%{value} hours"
678 label_f_hour_plural: "%{value} hours"
678 label_time_tracking: Time tracking
679 label_time_tracking: Time tracking
679 label_change_plural: Changes
680 label_change_plural: Changes
680 label_statistics: Statistics
681 label_statistics: Statistics
681 label_commits_per_month: Commits per month
682 label_commits_per_month: Commits per month
682 label_commits_per_author: Commits per author
683 label_commits_per_author: Commits per author
683 label_view_diff: View differences
684 label_view_diff: View differences
684 label_diff_inline: inline
685 label_diff_inline: inline
685 label_diff_side_by_side: side by side
686 label_diff_side_by_side: side by side
686 label_options: Options
687 label_options: Options
687 label_copy_workflow_from: Copy workflow from
688 label_copy_workflow_from: Copy workflow from
688 label_permissions_report: Permissions report
689 label_permissions_report: Permissions report
689 label_watched_issues: Watched issues
690 label_watched_issues: Watched issues
690 label_related_issues: Related issues
691 label_related_issues: Related issues
691 label_applied_status: Applied status
692 label_applied_status: Applied status
692 label_loading: Loading...
693 label_loading: Loading...
693 label_relation_new: New relation
694 label_relation_new: New relation
694 label_relation_delete: Delete relation
695 label_relation_delete: Delete relation
695 label_relates_to: related to
696 label_relates_to: related to
696 label_duplicates: duplicates
697 label_duplicates: duplicates
697 label_duplicated_by: duplicated by
698 label_duplicated_by: duplicated by
698 label_blocks: blocks
699 label_blocks: blocks
699 label_blocked_by: blocked by
700 label_blocked_by: blocked by
700 label_precedes: precedes
701 label_precedes: precedes
701 label_follows: follows
702 label_follows: follows
702 label_end_to_start: end to start
703 label_end_to_start: end to start
703 label_end_to_end: end to end
704 label_end_to_end: end to end
704 label_start_to_start: start to start
705 label_start_to_start: start to start
705 label_start_to_end: start to end
706 label_start_to_end: start to end
706 label_stay_logged_in: Stay logged in
707 label_stay_logged_in: Stay logged in
707 label_disabled: disabled
708 label_disabled: disabled
708 label_show_completed_versions: Show completed versions
709 label_show_completed_versions: Show completed versions
709 label_me: me
710 label_me: me
710 label_board: Forum
711 label_board: Forum
711 label_board_new: New forum
712 label_board_new: New forum
712 label_board_plural: Forums
713 label_board_plural: Forums
713 label_board_locked: Locked
714 label_board_locked: Locked
714 label_board_sticky: Sticky
715 label_board_sticky: Sticky
715 label_topic_plural: Topics
716 label_topic_plural: Topics
716 label_message_plural: Messages
717 label_message_plural: Messages
717 label_message_last: Last message
718 label_message_last: Last message
718 label_message_new: New message
719 label_message_new: New message
719 label_message_posted: Message added
720 label_message_posted: Message added
720 label_reply_plural: Replies
721 label_reply_plural: Replies
721 label_send_information: Send account information to the user
722 label_send_information: Send account information to the user
722 label_year: Year
723 label_year: Year
723 label_month: Month
724 label_month: Month
724 label_week: Week
725 label_week: Week
725 label_date_from: From
726 label_date_from: From
726 label_date_to: To
727 label_date_to: To
727 label_language_based: Based on user's language
728 label_language_based: Based on user's language
728 label_sort_by: "Sort by %{value}"
729 label_sort_by: "Sort by %{value}"
729 label_send_test_email: Send a test email
730 label_send_test_email: Send a test email
730 label_feeds_access_key: RSS access key
731 label_feeds_access_key: RSS access key
731 label_missing_feeds_access_key: Missing a RSS access key
732 label_missing_feeds_access_key: Missing a RSS access key
732 label_feeds_access_key_created_on: "RSS access key created %{value} ago"
733 label_feeds_access_key_created_on: "RSS access key created %{value} ago"
733 label_module_plural: Modules
734 label_module_plural: Modules
734 label_added_time_by: "Added by %{author} %{age} ago"
735 label_added_time_by: "Added by %{author} %{age} ago"
735 label_updated_time_by: "Updated by %{author} %{age} ago"
736 label_updated_time_by: "Updated by %{author} %{age} ago"
736 label_updated_time: "Updated %{value} ago"
737 label_updated_time: "Updated %{value} ago"
737 label_jump_to_a_project: Jump to a project...
738 label_jump_to_a_project: Jump to a project...
738 label_file_plural: Files
739 label_file_plural: Files
739 label_changeset_plural: Changesets
740 label_changeset_plural: Changesets
740 label_default_columns: Default columns
741 label_default_columns: Default columns
741 label_no_change_option: (No change)
742 label_no_change_option: (No change)
742 label_bulk_edit_selected_issues: Bulk edit selected issues
743 label_bulk_edit_selected_issues: Bulk edit selected issues
743 label_bulk_edit_selected_time_entries: Bulk edit selected time entries
744 label_bulk_edit_selected_time_entries: Bulk edit selected time entries
744 label_theme: Theme
745 label_theme: Theme
745 label_default: Default
746 label_default: Default
746 label_search_titles_only: Search titles only
747 label_search_titles_only: Search titles only
747 label_user_mail_option_all: "For any event on all my projects"
748 label_user_mail_option_all: "For any event on all my projects"
748 label_user_mail_option_selected: "For any event on the selected projects only..."
749 label_user_mail_option_selected: "For any event on the selected projects only..."
749 label_user_mail_option_none: "No events"
750 label_user_mail_option_none: "No events"
750 label_user_mail_option_only_my_events: "Only for things I watch or I'm involved in"
751 label_user_mail_option_only_my_events: "Only for things I watch or I'm involved in"
751 label_user_mail_option_only_assigned: "Only for things I am assigned to"
752 label_user_mail_option_only_assigned: "Only for things I am assigned to"
752 label_user_mail_option_only_owner: "Only for things I am the owner of"
753 label_user_mail_option_only_owner: "Only for things I am the owner of"
753 label_user_mail_no_self_notified: "I don't want to be notified of changes that I make myself"
754 label_user_mail_no_self_notified: "I don't want to be notified of changes that I make myself"
754 label_registration_activation_by_email: account activation by email
755 label_registration_activation_by_email: account activation by email
755 label_registration_manual_activation: manual account activation
756 label_registration_manual_activation: manual account activation
756 label_registration_automatic_activation: automatic account activation
757 label_registration_automatic_activation: automatic account activation
757 label_display_per_page: "Per page: %{value}"
758 label_display_per_page: "Per page: %{value}"
758 label_age: Age
759 label_age: Age
759 label_change_properties: Change properties
760 label_change_properties: Change properties
760 label_general: General
761 label_general: General
761 label_more: More
762 label_more: More
762 label_scm: SCM
763 label_scm: SCM
763 label_plugins: Plugins
764 label_plugins: Plugins
764 label_ldap_authentication: LDAP authentication
765 label_ldap_authentication: LDAP authentication
765 label_downloads_abbr: D/L
766 label_downloads_abbr: D/L
766 label_optional_description: Optional description
767 label_optional_description: Optional description
767 label_add_another_file: Add another file
768 label_add_another_file: Add another file
768 label_preferences: Preferences
769 label_preferences: Preferences
769 label_chronological_order: In chronological order
770 label_chronological_order: In chronological order
770 label_reverse_chronological_order: In reverse chronological order
771 label_reverse_chronological_order: In reverse chronological order
771 label_planning: Planning
772 label_planning: Planning
772 label_incoming_emails: Incoming emails
773 label_incoming_emails: Incoming emails
773 label_generate_key: Generate a key
774 label_generate_key: Generate a key
774 label_issue_watchers: Watchers
775 label_issue_watchers: Watchers
775 label_example: Example
776 label_example: Example
776 label_display: Display
777 label_display: Display
777 label_sort: Sort
778 label_sort: Sort
778 label_ascending: Ascending
779 label_ascending: Ascending
779 label_descending: Descending
780 label_descending: Descending
780 label_date_from_to: From %{start} to %{end}
781 label_date_from_to: From %{start} to %{end}
781 label_wiki_content_added: Wiki page added
782 label_wiki_content_added: Wiki page added
782 label_wiki_content_updated: Wiki page updated
783 label_wiki_content_updated: Wiki page updated
783 label_group: Group
784 label_group: Group
784 label_group_plural: Groups
785 label_group_plural: Groups
785 label_group_new: New group
786 label_group_new: New group
786 label_time_entry_plural: Spent time
787 label_time_entry_plural: Spent time
787 label_version_sharing_none: Not shared
788 label_version_sharing_none: Not shared
788 label_version_sharing_descendants: With subprojects
789 label_version_sharing_descendants: With subprojects
789 label_version_sharing_hierarchy: With project hierarchy
790 label_version_sharing_hierarchy: With project hierarchy
790 label_version_sharing_tree: With project tree
791 label_version_sharing_tree: With project tree
791 label_version_sharing_system: With all projects
792 label_version_sharing_system: With all projects
792 label_update_issue_done_ratios: Update issue done ratios
793 label_update_issue_done_ratios: Update issue done ratios
793 label_copy_source: Source
794 label_copy_source: Source
794 label_copy_target: Target
795 label_copy_target: Target
795 label_copy_same_as_target: Same as target
796 label_copy_same_as_target: Same as target
796 label_display_used_statuses_only: Only display statuses that are used by this tracker
797 label_display_used_statuses_only: Only display statuses that are used by this tracker
797 label_api_access_key: API access key
798 label_api_access_key: API access key
798 label_missing_api_access_key: Missing an API access key
799 label_missing_api_access_key: Missing an API access key
799 label_api_access_key_created_on: "API access key created %{value} ago"
800 label_api_access_key_created_on: "API access key created %{value} ago"
800 label_profile: Profile
801 label_profile: Profile
801 label_subtask_plural: Subtasks
802 label_subtask_plural: Subtasks
802 label_project_copy_notifications: Send email notifications during the project copy
803 label_project_copy_notifications: Send email notifications during the project copy
803 label_principal_search: "Search for user or group:"
804 label_principal_search: "Search for user or group:"
804 label_user_search: "Search for user:"
805 label_user_search: "Search for user:"
805 label_additional_workflow_transitions_for_author: Additional transitions allowed when the user is the author
806 label_additional_workflow_transitions_for_author: Additional transitions allowed when the user is the author
806 label_additional_workflow_transitions_for_assignee: Additional transitions allowed when the user is the assignee
807 label_additional_workflow_transitions_for_assignee: Additional transitions allowed when the user is the assignee
808 label_issues_visibility_all: All issues
809 label_issues_visibility_own: Issues created by or assigned to the user
807
810
808 button_login: Login
811 button_login: Login
809 button_submit: Submit
812 button_submit: Submit
810 button_save: Save
813 button_save: Save
811 button_check_all: Check all
814 button_check_all: Check all
812 button_uncheck_all: Uncheck all
815 button_uncheck_all: Uncheck all
813 button_collapse_all: Collapse all
816 button_collapse_all: Collapse all
814 button_expand_all: Expand all
817 button_expand_all: Expand all
815 button_delete: Delete
818 button_delete: Delete
816 button_create: Create
819 button_create: Create
817 button_create_and_continue: Create and continue
820 button_create_and_continue: Create and continue
818 button_test: Test
821 button_test: Test
819 button_edit: Edit
822 button_edit: Edit
820 button_edit_associated_wikipage: "Edit associated Wiki page: %{page_title}"
823 button_edit_associated_wikipage: "Edit associated Wiki page: %{page_title}"
821 button_add: Add
824 button_add: Add
822 button_change: Change
825 button_change: Change
823 button_apply: Apply
826 button_apply: Apply
824 button_clear: Clear
827 button_clear: Clear
825 button_lock: Lock
828 button_lock: Lock
826 button_unlock: Unlock
829 button_unlock: Unlock
827 button_download: Download
830 button_download: Download
828 button_list: List
831 button_list: List
829 button_view: View
832 button_view: View
830 button_move: Move
833 button_move: Move
831 button_move_and_follow: Move and follow
834 button_move_and_follow: Move and follow
832 button_back: Back
835 button_back: Back
833 button_cancel: Cancel
836 button_cancel: Cancel
834 button_activate: Activate
837 button_activate: Activate
835 button_sort: Sort
838 button_sort: Sort
836 button_log_time: Log time
839 button_log_time: Log time
837 button_rollback: Rollback to this version
840 button_rollback: Rollback to this version
838 button_watch: Watch
841 button_watch: Watch
839 button_unwatch: Unwatch
842 button_unwatch: Unwatch
840 button_reply: Reply
843 button_reply: Reply
841 button_archive: Archive
844 button_archive: Archive
842 button_unarchive: Unarchive
845 button_unarchive: Unarchive
843 button_reset: Reset
846 button_reset: Reset
844 button_rename: Rename
847 button_rename: Rename
845 button_change_password: Change password
848 button_change_password: Change password
846 button_copy: Copy
849 button_copy: Copy
847 button_copy_and_follow: Copy and follow
850 button_copy_and_follow: Copy and follow
848 button_annotate: Annotate
851 button_annotate: Annotate
849 button_update: Update
852 button_update: Update
850 button_configure: Configure
853 button_configure: Configure
851 button_quote: Quote
854 button_quote: Quote
852 button_duplicate: Duplicate
855 button_duplicate: Duplicate
853 button_show: Show
856 button_show: Show
854
857
855 status_active: active
858 status_active: active
856 status_registered: registered
859 status_registered: registered
857 status_locked: locked
860 status_locked: locked
858
861
859 version_status_open: open
862 version_status_open: open
860 version_status_locked: locked
863 version_status_locked: locked
861 version_status_closed: closed
864 version_status_closed: closed
862
865
863 field_active: Active
866 field_active: Active
864
867
865 text_select_mail_notifications: Select actions for which email notifications should be sent.
868 text_select_mail_notifications: Select actions for which email notifications should be sent.
866 text_regexp_info: eg. ^[A-Z0-9]+$
869 text_regexp_info: eg. ^[A-Z0-9]+$
867 text_min_max_length_info: 0 means no restriction
870 text_min_max_length_info: 0 means no restriction
868 text_project_destroy_confirmation: Are you sure you want to delete this project and related data?
871 text_project_destroy_confirmation: Are you sure you want to delete this project and related data?
869 text_subprojects_destroy_warning: "Its subproject(s): %{value} will be also deleted."
872 text_subprojects_destroy_warning: "Its subproject(s): %{value} will be also deleted."
870 text_workflow_edit: Select a role and a tracker to edit the workflow
873 text_workflow_edit: Select a role and a tracker to edit the workflow
871 text_are_you_sure: Are you sure?
874 text_are_you_sure: Are you sure?
872 text_are_you_sure_with_children: "Delete issue and all child issues?"
875 text_are_you_sure_with_children: "Delete issue and all child issues?"
873 text_journal_changed: "%{label} changed from %{old} to %{new}"
876 text_journal_changed: "%{label} changed from %{old} to %{new}"
874 text_journal_changed_no_detail: "%{label} updated"
877 text_journal_changed_no_detail: "%{label} updated"
875 text_journal_set_to: "%{label} set to %{value}"
878 text_journal_set_to: "%{label} set to %{value}"
876 text_journal_deleted: "%{label} deleted (%{old})"
879 text_journal_deleted: "%{label} deleted (%{old})"
877 text_journal_added: "%{label} %{value} added"
880 text_journal_added: "%{label} %{value} added"
878 text_tip_issue_begin_day: issue beginning this day
881 text_tip_issue_begin_day: issue beginning this day
879 text_tip_issue_end_day: issue ending this day
882 text_tip_issue_end_day: issue ending this day
880 text_tip_issue_begin_end_day: issue beginning and ending this day
883 text_tip_issue_begin_end_day: issue beginning and ending this day
881 text_project_identifier_info: 'Only lower case letters (a-z), numbers and dashes are allowed.<br />Once saved, the identifier cannot be changed.'
884 text_project_identifier_info: 'Only lower case letters (a-z), numbers and dashes are allowed.<br />Once saved, the identifier cannot be changed.'
882 text_caracters_maximum: "%{count} characters maximum."
885 text_caracters_maximum: "%{count} characters maximum."
883 text_caracters_minimum: "Must be at least %{count} characters long."
886 text_caracters_minimum: "Must be at least %{count} characters long."
884 text_length_between: "Length between %{min} and %{max} characters."
887 text_length_between: "Length between %{min} and %{max} characters."
885 text_tracker_no_workflow: No workflow defined for this tracker
888 text_tracker_no_workflow: No workflow defined for this tracker
886 text_unallowed_characters: Unallowed characters
889 text_unallowed_characters: Unallowed characters
887 text_comma_separated: Multiple values allowed (comma separated).
890 text_comma_separated: Multiple values allowed (comma separated).
888 text_line_separated: Multiple values allowed (one line for each value).
891 text_line_separated: Multiple values allowed (one line for each value).
889 text_issues_ref_in_commit_messages: Referencing and fixing issues in commit messages
892 text_issues_ref_in_commit_messages: Referencing and fixing issues in commit messages
890 text_issue_added: "Issue %{id} has been reported by %{author}."
893 text_issue_added: "Issue %{id} has been reported by %{author}."
891 text_issue_updated: "Issue %{id} has been updated by %{author}."
894 text_issue_updated: "Issue %{id} has been updated by %{author}."
892 text_wiki_destroy_confirmation: Are you sure you want to delete this wiki and all its content?
895 text_wiki_destroy_confirmation: Are you sure you want to delete this wiki and all its content?
893 text_issue_category_destroy_question: "Some issues (%{count}) are assigned to this category. What do you want to do?"
896 text_issue_category_destroy_question: "Some issues (%{count}) are assigned to this category. What do you want to do?"
894 text_issue_category_destroy_assignments: Remove category assignments
897 text_issue_category_destroy_assignments: Remove category assignments
895 text_issue_category_reassign_to: Reassign issues to this category
898 text_issue_category_reassign_to: Reassign issues to this category
896 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)."
899 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)."
897 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."
900 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."
898 text_load_default_configuration: Load the default configuration
901 text_load_default_configuration: Load the default configuration
899 text_status_changed_by_changeset: "Applied in changeset %{value}."
902 text_status_changed_by_changeset: "Applied in changeset %{value}."
900 text_time_logged_by_changeset: "Applied in changeset %{value}."
903 text_time_logged_by_changeset: "Applied in changeset %{value}."
901 text_issues_destroy_confirmation: 'Are you sure you want to delete the selected issue(s)?'
904 text_issues_destroy_confirmation: 'Are you sure you want to delete the selected issue(s)?'
902 text_time_entries_destroy_confirmation: 'Are you sure you want to delete the selected time entr(y/ies)?'
905 text_time_entries_destroy_confirmation: 'Are you sure you want to delete the selected time entr(y/ies)?'
903 text_select_project_modules: 'Select modules to enable for this project:'
906 text_select_project_modules: 'Select modules to enable for this project:'
904 text_default_administrator_account_changed: Default administrator account changed
907 text_default_administrator_account_changed: Default administrator account changed
905 text_file_repository_writable: Attachments directory writable
908 text_file_repository_writable: Attachments directory writable
906 text_plugin_assets_writable: Plugin assets directory writable
909 text_plugin_assets_writable: Plugin assets directory writable
907 text_rmagick_available: RMagick available (optional)
910 text_rmagick_available: RMagick available (optional)
908 text_destroy_time_entries_question: "%{hours} hours were reported on the issues you are about to delete. What do you want to do?"
911 text_destroy_time_entries_question: "%{hours} hours were reported on the issues you are about to delete. What do you want to do?"
909 text_destroy_time_entries: Delete reported hours
912 text_destroy_time_entries: Delete reported hours
910 text_assign_time_entries_to_project: Assign reported hours to the project
913 text_assign_time_entries_to_project: Assign reported hours to the project
911 text_reassign_time_entries: 'Reassign reported hours to this issue:'
914 text_reassign_time_entries: 'Reassign reported hours to this issue:'
912 text_user_wrote: "%{value} wrote:"
915 text_user_wrote: "%{value} wrote:"
913 text_enumeration_destroy_question: "%{count} objects are assigned to this value."
916 text_enumeration_destroy_question: "%{count} objects are assigned to this value."
914 text_enumeration_category_reassign_to: 'Reassign them to this value:'
917 text_enumeration_category_reassign_to: 'Reassign them to this value:'
915 text_email_delivery_not_configured: "Email delivery is not configured, and notifications are disabled.\nConfigure your SMTP server in config/configuration.yml and restart the application to enable them."
918 text_email_delivery_not_configured: "Email delivery is not configured, and notifications are disabled.\nConfigure your SMTP server in config/configuration.yml and restart the application to enable them."
916 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."
919 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."
917 text_diff_truncated: '... This diff was truncated because it exceeds the maximum size that can be displayed.'
920 text_diff_truncated: '... This diff was truncated because it exceeds the maximum size that can be displayed.'
918 text_custom_field_possible_values_info: 'One line for each value'
921 text_custom_field_possible_values_info: 'One line for each value'
919 text_wiki_page_destroy_question: "This page has %{descendants} child page(s) and descendant(s). What do you want to do?"
922 text_wiki_page_destroy_question: "This page has %{descendants} child page(s) and descendant(s). What do you want to do?"
920 text_wiki_page_nullify_children: "Keep child pages as root pages"
923 text_wiki_page_nullify_children: "Keep child pages as root pages"
921 text_wiki_page_destroy_children: "Delete child pages and all their descendants"
924 text_wiki_page_destroy_children: "Delete child pages and all their descendants"
922 text_wiki_page_reassign_children: "Reassign child pages to this parent page"
925 text_wiki_page_reassign_children: "Reassign child pages to this parent page"
923 text_own_membership_delete_confirmation: "You are about to remove some or all of your permissions and may no longer be able to edit this project after that.\nAre you sure you want to continue?"
926 text_own_membership_delete_confirmation: "You are about to remove some or all of your permissions and may no longer be able to edit this project after that.\nAre you sure you want to continue?"
924 text_zoom_in: Zoom in
927 text_zoom_in: Zoom in
925 text_zoom_out: Zoom out
928 text_zoom_out: Zoom out
926 text_warn_on_leaving_unsaved: "The current page contains unsaved text that will be lost if you leave this page."
929 text_warn_on_leaving_unsaved: "The current page contains unsaved text that will be lost if you leave this page."
927
930
928 default_role_manager: Manager
931 default_role_manager: Manager
929 default_role_developer: Developer
932 default_role_developer: Developer
930 default_role_reporter: Reporter
933 default_role_reporter: Reporter
931 default_tracker_bug: Bug
934 default_tracker_bug: Bug
932 default_tracker_feature: Feature
935 default_tracker_feature: Feature
933 default_tracker_support: Support
936 default_tracker_support: Support
934 default_issue_status_new: New
937 default_issue_status_new: New
935 default_issue_status_in_progress: In Progress
938 default_issue_status_in_progress: In Progress
936 default_issue_status_resolved: Resolved
939 default_issue_status_resolved: Resolved
937 default_issue_status_feedback: Feedback
940 default_issue_status_feedback: Feedback
938 default_issue_status_closed: Closed
941 default_issue_status_closed: Closed
939 default_issue_status_rejected: Rejected
942 default_issue_status_rejected: Rejected
940 default_doc_category_user: User documentation
943 default_doc_category_user: User documentation
941 default_doc_category_tech: Technical documentation
944 default_doc_category_tech: Technical documentation
942 default_priority_low: Low
945 default_priority_low: Low
943 default_priority_normal: Normal
946 default_priority_normal: Normal
944 default_priority_high: High
947 default_priority_high: High
945 default_priority_urgent: Urgent
948 default_priority_urgent: Urgent
946 default_priority_immediate: Immediate
949 default_priority_immediate: Immediate
947 default_activity_design: Design
950 default_activity_design: Design
948 default_activity_development: Development
951 default_activity_development: Development
949
952
950 enumeration_issue_priorities: Issue priorities
953 enumeration_issue_priorities: Issue priorities
951 enumeration_doc_categories: Document categories
954 enumeration_doc_categories: Document categories
952 enumeration_activities: Activities (time tracking)
955 enumeration_activities: Activities (time tracking)
953 enumeration_system_activity: System Activity
956 enumeration_system_activity: System Activity
@@ -1,969 +1,972
1 # French translations for Ruby on Rails
1 # French translations for Ruby on Rails
2 # by Christian Lescuyer (christian@flyingcoders.com)
2 # by Christian Lescuyer (christian@flyingcoders.com)
3 # contributor: Sebastien Grosjean - ZenCocoon.com
3 # contributor: Sebastien Grosjean - ZenCocoon.com
4 # contributor: Thibaut Cuvelier - Developpez.com
4 # contributor: Thibaut Cuvelier - Developpez.com
5
5
6 fr:
6 fr:
7 direction: ltr
7 direction: ltr
8 date:
8 date:
9 formats:
9 formats:
10 default: "%d/%m/%Y"
10 default: "%d/%m/%Y"
11 short: "%e %b"
11 short: "%e %b"
12 long: "%e %B %Y"
12 long: "%e %B %Y"
13 long_ordinal: "%e %B %Y"
13 long_ordinal: "%e %B %Y"
14 only_day: "%e"
14 only_day: "%e"
15
15
16 day_names: [dimanche, lundi, mardi, mercredi, jeudi, vendredi, samedi]
16 day_names: [dimanche, lundi, mardi, mercredi, jeudi, vendredi, samedi]
17 abbr_day_names: [dim, lun, mar, mer, jeu, ven, sam]
17 abbr_day_names: [dim, lun, mar, mer, jeu, ven, sam]
18 month_names: [~, janvier, fΓ©vrier, mars, avril, mai, juin, juillet, aoΓ»t, septembre, octobre, novembre, dΓ©cembre]
18 month_names: [~, janvier, fΓ©vrier, mars, avril, mai, juin, juillet, aoΓ»t, septembre, octobre, novembre, dΓ©cembre]
19 abbr_month_names: [~, jan., fΓ©v., mar., avr., mai, juin, juil., aoΓ»t, sept., oct., nov., dΓ©c.]
19 abbr_month_names: [~, jan., fΓ©v., mar., avr., mai, juin, juil., aoΓ»t, sept., oct., nov., dΓ©c.]
20 order: [ :day, :month, :year ]
20 order: [ :day, :month, :year ]
21
21
22 time:
22 time:
23 formats:
23 formats:
24 default: "%d/%m/%Y %H:%M"
24 default: "%d/%m/%Y %H:%M"
25 time: "%H:%M"
25 time: "%H:%M"
26 short: "%d %b %H:%M"
26 short: "%d %b %H:%M"
27 long: "%A %d %B %Y %H:%M:%S %Z"
27 long: "%A %d %B %Y %H:%M:%S %Z"
28 long_ordinal: "%A %d %B %Y %H:%M:%S %Z"
28 long_ordinal: "%A %d %B %Y %H:%M:%S %Z"
29 only_second: "%S"
29 only_second: "%S"
30 am: 'am'
30 am: 'am'
31 pm: 'pm'
31 pm: 'pm'
32
32
33 datetime:
33 datetime:
34 distance_in_words:
34 distance_in_words:
35 half_a_minute: "30 secondes"
35 half_a_minute: "30 secondes"
36 less_than_x_seconds:
36 less_than_x_seconds:
37 zero: "moins d'une seconde"
37 zero: "moins d'une seconde"
38 one: "moins d'uneΒ seconde"
38 one: "moins d'uneΒ seconde"
39 other: "moins de %{count}Β secondes"
39 other: "moins de %{count}Β secondes"
40 x_seconds:
40 x_seconds:
41 one: "1Β seconde"
41 one: "1Β seconde"
42 other: "%{count}Β secondes"
42 other: "%{count}Β secondes"
43 less_than_x_minutes:
43 less_than_x_minutes:
44 zero: "moins d'une minute"
44 zero: "moins d'une minute"
45 one: "moins d'uneΒ minute"
45 one: "moins d'uneΒ minute"
46 other: "moins de %{count}Β minutes"
46 other: "moins de %{count}Β minutes"
47 x_minutes:
47 x_minutes:
48 one: "1Β minute"
48 one: "1Β minute"
49 other: "%{count}Β minutes"
49 other: "%{count}Β minutes"
50 about_x_hours:
50 about_x_hours:
51 one: "environ une heure"
51 one: "environ une heure"
52 other: "environ %{count}Β heures"
52 other: "environ %{count}Β heures"
53 x_days:
53 x_days:
54 one: "unΒ jour"
54 one: "unΒ jour"
55 other: "%{count}Β jours"
55 other: "%{count}Β jours"
56 about_x_months:
56 about_x_months:
57 one: "environ un mois"
57 one: "environ un mois"
58 other: "environ %{count}Β mois"
58 other: "environ %{count}Β mois"
59 x_months:
59 x_months:
60 one: "unΒ mois"
60 one: "unΒ mois"
61 other: "%{count}Β mois"
61 other: "%{count}Β mois"
62 about_x_years:
62 about_x_years:
63 one: "environ un an"
63 one: "environ un an"
64 other: "environ %{count}Β ans"
64 other: "environ %{count}Β ans"
65 over_x_years:
65 over_x_years:
66 one: "plus d'un an"
66 one: "plus d'un an"
67 other: "plus de %{count}Β ans"
67 other: "plus de %{count}Β ans"
68 almost_x_years:
68 almost_x_years:
69 one: "presqu'un an"
69 one: "presqu'un an"
70 other: "presque %{count} ans"
70 other: "presque %{count} ans"
71 prompts:
71 prompts:
72 year: "AnnΓ©e"
72 year: "AnnΓ©e"
73 month: "Mois"
73 month: "Mois"
74 day: "Jour"
74 day: "Jour"
75 hour: "Heure"
75 hour: "Heure"
76 minute: "Minute"
76 minute: "Minute"
77 second: "Seconde"
77 second: "Seconde"
78
78
79 number:
79 number:
80 format:
80 format:
81 precision: 3
81 precision: 3
82 separator: ','
82 separator: ','
83 delimiter: 'Β '
83 delimiter: 'Β '
84 currency:
84 currency:
85 format:
85 format:
86 unit: '€'
86 unit: '€'
87 precision: 2
87 precision: 2
88 format: '%nΒ %u'
88 format: '%nΒ %u'
89 human:
89 human:
90 format:
90 format:
91 precision: 2
91 precision: 2
92 storage_units:
92 storage_units:
93 format: "%n %u"
93 format: "%n %u"
94 units:
94 units:
95 byte:
95 byte:
96 one: "octet"
96 one: "octet"
97 other: "octet"
97 other: "octet"
98 kb: "ko"
98 kb: "ko"
99 mb: "Mo"
99 mb: "Mo"
100 gb: "Go"
100 gb: "Go"
101 tb: "To"
101 tb: "To"
102
102
103 support:
103 support:
104 array:
104 array:
105 sentence_connector: 'et'
105 sentence_connector: 'et'
106 skip_last_comma: true
106 skip_last_comma: true
107 word_connector: ", "
107 word_connector: ", "
108 two_words_connector: " et "
108 two_words_connector: " et "
109 last_word_connector: " et "
109 last_word_connector: " et "
110
110
111 activerecord:
111 activerecord:
112 errors:
112 errors:
113 template:
113 template:
114 header:
114 header:
115 one: "Impossible d'enregistrer %{model} : une erreur"
115 one: "Impossible d'enregistrer %{model} : une erreur"
116 other: "Impossible d'enregistrer %{model} : %{count} erreurs."
116 other: "Impossible d'enregistrer %{model} : %{count} erreurs."
117 body: "Veuillez vΓ©rifier les champs suivantsΒ :"
117 body: "Veuillez vΓ©rifier les champs suivantsΒ :"
118 messages:
118 messages:
119 inclusion: "n'est pas inclus(e) dans la liste"
119 inclusion: "n'est pas inclus(e) dans la liste"
120 exclusion: "n'est pas disponible"
120 exclusion: "n'est pas disponible"
121 invalid: "n'est pas valide"
121 invalid: "n'est pas valide"
122 confirmation: "ne concorde pas avec la confirmation"
122 confirmation: "ne concorde pas avec la confirmation"
123 accepted: "doit Γͺtre acceptΓ©(e)"
123 accepted: "doit Γͺtre acceptΓ©(e)"
124 empty: "doit Γͺtre renseignΓ©(e)"
124 empty: "doit Γͺtre renseignΓ©(e)"
125 blank: "doit Γͺtre renseignΓ©(e)"
125 blank: "doit Γͺtre renseignΓ©(e)"
126 too_long: "est trop long (pas plus de %{count} caractères)"
126 too_long: "est trop long (pas plus de %{count} caractères)"
127 too_short: "est trop court (au moins %{count} caractères)"
127 too_short: "est trop court (au moins %{count} caractères)"
128 wrong_length: "ne fait pas la bonne longueur (doit comporter %{count} caractères)"
128 wrong_length: "ne fait pas la bonne longueur (doit comporter %{count} caractères)"
129 taken: "est dΓ©jΓ  utilisΓ©"
129 taken: "est dΓ©jΓ  utilisΓ©"
130 not_a_number: "n'est pas un nombre"
130 not_a_number: "n'est pas un nombre"
131 not_a_date: "n'est pas une date valide"
131 not_a_date: "n'est pas une date valide"
132 greater_than: "doit Γͺtre supΓ©rieur Γ  %{count}"
132 greater_than: "doit Γͺtre supΓ©rieur Γ  %{count}"
133 greater_than_or_equal_to: "doit Γͺtre supΓ©rieur ou Γ©gal Γ  %{count}"
133 greater_than_or_equal_to: "doit Γͺtre supΓ©rieur ou Γ©gal Γ  %{count}"
134 equal_to: "doit Γͺtre Γ©gal Γ  %{count}"
134 equal_to: "doit Γͺtre Γ©gal Γ  %{count}"
135 less_than: "doit Γͺtre infΓ©rieur Γ  %{count}"
135 less_than: "doit Γͺtre infΓ©rieur Γ  %{count}"
136 less_than_or_equal_to: "doit Γͺtre infΓ©rieur ou Γ©gal Γ  %{count}"
136 less_than_or_equal_to: "doit Γͺtre infΓ©rieur ou Γ©gal Γ  %{count}"
137 odd: "doit Γͺtre impair"
137 odd: "doit Γͺtre impair"
138 even: "doit Γͺtre pair"
138 even: "doit Γͺtre pair"
139 greater_than_start_date: "doit Γͺtre postΓ©rieure Γ  la date de dΓ©but"
139 greater_than_start_date: "doit Γͺtre postΓ©rieure Γ  la date de dΓ©but"
140 not_same_project: "n'appartient pas au mΓͺme projet"
140 not_same_project: "n'appartient pas au mΓͺme projet"
141 circular_dependency: "Cette relation crΓ©erait une dΓ©pendance circulaire"
141 circular_dependency: "Cette relation crΓ©erait une dΓ©pendance circulaire"
142 cant_link_an_issue_with_a_descendant: "Une demande ne peut pas Γͺtre liΓ©e Γ  l'une de ses sous-tΓ’ches"
142 cant_link_an_issue_with_a_descendant: "Une demande ne peut pas Γͺtre liΓ©e Γ  l'une de ses sous-tΓ’ches"
143
143
144 actionview_instancetag_blank_option: Choisir
144 actionview_instancetag_blank_option: Choisir
145
145
146 general_text_No: 'Non'
146 general_text_No: 'Non'
147 general_text_Yes: 'Oui'
147 general_text_Yes: 'Oui'
148 general_text_no: 'non'
148 general_text_no: 'non'
149 general_text_yes: 'oui'
149 general_text_yes: 'oui'
150 general_lang_name: 'FranΓ§ais'
150 general_lang_name: 'FranΓ§ais'
151 general_csv_separator: ';'
151 general_csv_separator: ';'
152 general_csv_decimal_separator: ','
152 general_csv_decimal_separator: ','
153 general_csv_encoding: ISO-8859-1
153 general_csv_encoding: ISO-8859-1
154 general_pdf_encoding: UTF-8
154 general_pdf_encoding: UTF-8
155 general_first_day_of_week: '1'
155 general_first_day_of_week: '1'
156
156
157 notice_account_updated: Le compte a été mis à jour avec succès.
157 notice_account_updated: Le compte a été mis à jour avec succès.
158 notice_account_invalid_creditentials: Identifiant ou mot de passe invalide.
158 notice_account_invalid_creditentials: Identifiant ou mot de passe invalide.
159 notice_account_password_updated: Mot de passe mis à jour avec succès.
159 notice_account_password_updated: Mot de passe mis à jour avec succès.
160 notice_account_wrong_password: Mot de passe incorrect
160 notice_account_wrong_password: Mot de passe incorrect
161 notice_account_register_done: Un message contenant les instructions pour activer votre compte vous a Γ©tΓ© envoyΓ©.
161 notice_account_register_done: Un message contenant les instructions pour activer votre compte vous a Γ©tΓ© envoyΓ©.
162 notice_account_unknown_email: Aucun compte ne correspond Γ  cette adresse.
162 notice_account_unknown_email: Aucun compte ne correspond Γ  cette adresse.
163 notice_can_t_change_password: Ce compte utilise une authentification externe. Impossible de changer le mot de passe.
163 notice_can_t_change_password: Ce compte utilise une authentification externe. Impossible de changer le mot de passe.
164 notice_account_lost_email_sent: Un message contenant les instructions pour choisir un nouveau mot de passe vous a Γ©tΓ© envoyΓ©.
164 notice_account_lost_email_sent: Un message contenant les instructions pour choisir un nouveau mot de passe vous a Γ©tΓ© envoyΓ©.
165 notice_account_activated: Votre compte a Γ©tΓ© activΓ©. Vous pouvez Γ  prΓ©sent vous connecter.
165 notice_account_activated: Votre compte a Γ©tΓ© activΓ©. Vous pouvez Γ  prΓ©sent vous connecter.
166 notice_successful_create: Création effectuée avec succès.
166 notice_successful_create: Création effectuée avec succès.
167 notice_successful_update: Mise à jour effectuée avec succès.
167 notice_successful_update: Mise à jour effectuée avec succès.
168 notice_successful_delete: Suppression effectuée avec succès.
168 notice_successful_delete: Suppression effectuée avec succès.
169 notice_successful_connection: Connexion rΓ©ussie.
169 notice_successful_connection: Connexion rΓ©ussie.
170 notice_file_not_found: "La page Γ  laquelle vous souhaitez accΓ©der n'existe pas ou a Γ©tΓ© supprimΓ©e."
170 notice_file_not_found: "La page Γ  laquelle vous souhaitez accΓ©der n'existe pas ou a Γ©tΓ© supprimΓ©e."
171 notice_locking_conflict: Les donnΓ©es ont Γ©tΓ© mises Γ  jour par un autre utilisateur. Mise Γ  jour impossible.
171 notice_locking_conflict: Les donnΓ©es ont Γ©tΓ© mises Γ  jour par un autre utilisateur. Mise Γ  jour impossible.
172 notice_not_authorized: "Vous n'Γͺtes pas autorisΓ© Γ  accΓ©der Γ  cette page."
172 notice_not_authorized: "Vous n'Γͺtes pas autorisΓ© Γ  accΓ©der Γ  cette page."
173 notice_not_authorized_archived_project: Le projet auquel vous tentez d'accΓ©der a Γ©tΓ© archivΓ©.
173 notice_not_authorized_archived_project: Le projet auquel vous tentez d'accΓ©der a Γ©tΓ© archivΓ©.
174 notice_email_sent: "Un email a Γ©tΓ© envoyΓ© Γ  %{value}"
174 notice_email_sent: "Un email a Γ©tΓ© envoyΓ© Γ  %{value}"
175 notice_email_error: "Erreur lors de l'envoi de l'email (%{value})"
175 notice_email_error: "Erreur lors de l'envoi de l'email (%{value})"
176 notice_feeds_access_key_reseted: "Votre clé d'accès aux flux RSS a été réinitialisée."
176 notice_feeds_access_key_reseted: "Votre clé d'accès aux flux RSS a été réinitialisée."
177 notice_failed_to_save_issues: "%{count} demande(s) sur les %{total} sΓ©lectionnΓ©es n'ont pas pu Γͺtre mise(s) Γ  jour : %{ids}."
177 notice_failed_to_save_issues: "%{count} demande(s) sur les %{total} sΓ©lectionnΓ©es n'ont pas pu Γͺtre mise(s) Γ  jour : %{ids}."
178 notice_no_issue_selected: "Aucune demande sΓ©lectionnΓ©e ! Cochez les demandes que vous voulez mettre Γ  jour."
178 notice_no_issue_selected: "Aucune demande sΓ©lectionnΓ©e ! Cochez les demandes que vous voulez mettre Γ  jour."
179 notice_account_pending: "Votre compte a été créé et attend l'approbation de l'administrateur."
179 notice_account_pending: "Votre compte a été créé et attend l'approbation de l'administrateur."
180 notice_default_data_loaded: Paramétrage par défaut chargé avec succès.
180 notice_default_data_loaded: Paramétrage par défaut chargé avec succès.
181 notice_unable_delete_version: Impossible de supprimer cette version.
181 notice_unable_delete_version: Impossible de supprimer cette version.
182 notice_issue_done_ratios_updated: L'avancement des demandes a Γ©tΓ© mis Γ  jour.
182 notice_issue_done_ratios_updated: L'avancement des demandes a Γ©tΓ© mis Γ  jour.
183 notice_api_access_key_reseted: Votre clé d'accès API a été réinitialisée.
183 notice_api_access_key_reseted: Votre clé d'accès API a été réinitialisée.
184 notice_gantt_chart_truncated: "Le diagramme a Γ©tΓ© tronquΓ© car il excΓ¨de le nombre maximal d'Γ©lΓ©ments pouvant Γͺtre affichΓ©s (%{max})"
184 notice_gantt_chart_truncated: "Le diagramme a Γ©tΓ© tronquΓ© car il excΓ¨de le nombre maximal d'Γ©lΓ©ments pouvant Γͺtre affichΓ©s (%{max})"
185
185
186 error_can_t_load_default_data: "Une erreur s'est produite lors du chargement du paramΓ©trage : %{value}"
186 error_can_t_load_default_data: "Une erreur s'est produite lors du chargement du paramΓ©trage : %{value}"
187 error_scm_not_found: "L'entrΓ©e et/ou la rΓ©vision demandΓ©e n'existe pas dans le dΓ©pΓ΄t."
187 error_scm_not_found: "L'entrΓ©e et/ou la rΓ©vision demandΓ©e n'existe pas dans le dΓ©pΓ΄t."
188 error_scm_command_failed: "Une erreur s'est produite lors de l'accès au dépôt : %{value}"
188 error_scm_command_failed: "Une erreur s'est produite lors de l'accès au dépôt : %{value}"
189 error_scm_annotate: "L'entrΓ©e n'existe pas ou ne peut pas Γͺtre annotΓ©e."
189 error_scm_annotate: "L'entrΓ©e n'existe pas ou ne peut pas Γͺtre annotΓ©e."
190 error_issue_not_found_in_project: "La demande n'existe pas ou n'appartient pas Γ  ce projet"
190 error_issue_not_found_in_project: "La demande n'existe pas ou n'appartient pas Γ  ce projet"
191 error_can_not_reopen_issue_on_closed_version: 'Une demande assignΓ©e Γ  une version fermΓ©e ne peut pas Γͺtre rΓ©ouverte'
191 error_can_not_reopen_issue_on_closed_version: 'Une demande assignΓ©e Γ  une version fermΓ©e ne peut pas Γͺtre rΓ©ouverte'
192 error_can_not_archive_project: "Ce projet ne peut pas Γͺtre archivΓ©"
192 error_can_not_archive_project: "Ce projet ne peut pas Γͺtre archivΓ©"
193 error_workflow_copy_source: 'Veuillez sΓ©lectionner un tracker et/ou un rΓ΄le source'
193 error_workflow_copy_source: 'Veuillez sΓ©lectionner un tracker et/ou un rΓ΄le source'
194 error_workflow_copy_target: 'Veuillez sΓ©lectionner les trackers et rΓ΄les cibles'
194 error_workflow_copy_target: 'Veuillez sΓ©lectionner les trackers et rΓ΄les cibles'
195 error_issue_done_ratios_not_updated: L'avancement des demandes n'a pas pu Γͺtre mis Γ  jour.
195 error_issue_done_ratios_not_updated: L'avancement des demandes n'a pas pu Γͺtre mis Γ  jour.
196
196
197 warning_attachments_not_saved: "%{count} fichier(s) n'ont pas pu Γͺtre sauvegardΓ©s."
197 warning_attachments_not_saved: "%{count} fichier(s) n'ont pas pu Γͺtre sauvegardΓ©s."
198
198
199 mail_subject_lost_password: "Votre mot de passe %{value}"
199 mail_subject_lost_password: "Votre mot de passe %{value}"
200 mail_body_lost_password: 'Pour changer votre mot de passe, cliquez sur le lien suivant :'
200 mail_body_lost_password: 'Pour changer votre mot de passe, cliquez sur le lien suivant :'
201 mail_subject_register: "Activation de votre compte %{value}"
201 mail_subject_register: "Activation de votre compte %{value}"
202 mail_body_register: 'Pour activer votre compte, cliquez sur le lien suivant :'
202 mail_body_register: 'Pour activer votre compte, cliquez sur le lien suivant :'
203 mail_body_account_information_external: "Vous pouvez utiliser votre compte %{value} pour vous connecter."
203 mail_body_account_information_external: "Vous pouvez utiliser votre compte %{value} pour vous connecter."
204 mail_body_account_information: Paramètres de connexion de votre compte
204 mail_body_account_information: Paramètres de connexion de votre compte
205 mail_subject_account_activation_request: "Demande d'activation d'un compte %{value}"
205 mail_subject_account_activation_request: "Demande d'activation d'un compte %{value}"
206 mail_body_account_activation_request: "Un nouvel utilisateur (%{value}) s'est inscrit. Son compte nΓ©cessite votre approbation :"
206 mail_body_account_activation_request: "Un nouvel utilisateur (%{value}) s'est inscrit. Son compte nΓ©cessite votre approbation :"
207 mail_subject_reminder: "%{count} demande(s) arrivent Γ  Γ©chΓ©ance (%{days})"
207 mail_subject_reminder: "%{count} demande(s) arrivent Γ  Γ©chΓ©ance (%{days})"
208 mail_body_reminder: "%{count} demande(s) qui vous sont assignΓ©es arrivent Γ  Γ©chΓ©ance dans les %{days} prochains jours :"
208 mail_body_reminder: "%{count} demande(s) qui vous sont assignΓ©es arrivent Γ  Γ©chΓ©ance dans les %{days} prochains jours :"
209 mail_subject_wiki_content_added: "Page wiki '%{id}' ajoutΓ©e"
209 mail_subject_wiki_content_added: "Page wiki '%{id}' ajoutΓ©e"
210 mail_body_wiki_content_added: "La page wiki '%{id}' a Γ©tΓ© ajoutΓ©e par %{author}."
210 mail_body_wiki_content_added: "La page wiki '%{id}' a Γ©tΓ© ajoutΓ©e par %{author}."
211 mail_subject_wiki_content_updated: "Page wiki '%{id}' mise Γ  jour"
211 mail_subject_wiki_content_updated: "Page wiki '%{id}' mise Γ  jour"
212 mail_body_wiki_content_updated: "La page wiki '%{id}' a Γ©tΓ© mise Γ  jour par %{author}."
212 mail_body_wiki_content_updated: "La page wiki '%{id}' a Γ©tΓ© mise Γ  jour par %{author}."
213
213
214 gui_validation_error: 1 erreur
214 gui_validation_error: 1 erreur
215 gui_validation_error_plural: "%{count} erreurs"
215 gui_validation_error_plural: "%{count} erreurs"
216
216
217 field_name: Nom
217 field_name: Nom
218 field_description: Description
218 field_description: Description
219 field_summary: RΓ©sumΓ©
219 field_summary: RΓ©sumΓ©
220 field_is_required: Obligatoire
220 field_is_required: Obligatoire
221 field_firstname: PrΓ©nom
221 field_firstname: PrΓ©nom
222 field_lastname: Nom
222 field_lastname: Nom
223 field_mail: "Email "
223 field_mail: "Email "
224 field_filename: Fichier
224 field_filename: Fichier
225 field_filesize: Taille
225 field_filesize: Taille
226 field_downloads: TΓ©lΓ©chargements
226 field_downloads: TΓ©lΓ©chargements
227 field_author: Auteur
227 field_author: Auteur
228 field_created_on: "Créé "
228 field_created_on: "Créé "
229 field_updated_on: "Mis-Γ -jour "
229 field_updated_on: "Mis-Γ -jour "
230 field_field_format: Format
230 field_field_format: Format
231 field_is_for_all: Pour tous les projets
231 field_is_for_all: Pour tous les projets
232 field_possible_values: Valeurs possibles
232 field_possible_values: Valeurs possibles
233 field_regexp: Expression régulière
233 field_regexp: Expression régulière
234 field_min_length: Longueur minimum
234 field_min_length: Longueur minimum
235 field_max_length: Longueur maximum
235 field_max_length: Longueur maximum
236 field_value: Valeur
236 field_value: Valeur
237 field_category: CatΓ©gorie
237 field_category: CatΓ©gorie
238 field_title: Titre
238 field_title: Titre
239 field_project: Projet
239 field_project: Projet
240 field_issue: Demande
240 field_issue: Demande
241 field_status: Statut
241 field_status: Statut
242 field_notes: Notes
242 field_notes: Notes
243 field_is_closed: Demande fermΓ©e
243 field_is_closed: Demande fermΓ©e
244 field_is_default: Valeur par dΓ©faut
244 field_is_default: Valeur par dΓ©faut
245 field_tracker: Tracker
245 field_tracker: Tracker
246 field_subject: Sujet
246 field_subject: Sujet
247 field_due_date: EchΓ©ance
247 field_due_date: EchΓ©ance
248 field_assigned_to: AssignΓ© Γ 
248 field_assigned_to: AssignΓ© Γ 
249 field_priority: PrioritΓ©
249 field_priority: PrioritΓ©
250 field_fixed_version: Version cible
250 field_fixed_version: Version cible
251 field_user: Utilisateur
251 field_user: Utilisateur
252 field_role: RΓ΄le
252 field_role: RΓ΄le
253 field_homepage: "Site web "
253 field_homepage: "Site web "
254 field_is_public: Public
254 field_is_public: Public
255 field_parent: Sous-projet de
255 field_parent: Sous-projet de
256 field_is_in_roadmap: Demandes affichΓ©es dans la roadmap
256 field_is_in_roadmap: Demandes affichΓ©es dans la roadmap
257 field_login: "Identifiant "
257 field_login: "Identifiant "
258 field_mail_notification: Notifications par mail
258 field_mail_notification: Notifications par mail
259 field_admin: Administrateur
259 field_admin: Administrateur
260 field_last_login_on: "Dernière connexion "
260 field_last_login_on: "Dernière connexion "
261 field_language: Langue
261 field_language: Langue
262 field_effective_date: Date
262 field_effective_date: Date
263 field_password: Mot de passe
263 field_password: Mot de passe
264 field_new_password: Nouveau mot de passe
264 field_new_password: Nouveau mot de passe
265 field_password_confirmation: Confirmation
265 field_password_confirmation: Confirmation
266 field_version: Version
266 field_version: Version
267 field_type: Type
267 field_type: Type
268 field_host: HΓ΄te
268 field_host: HΓ΄te
269 field_port: Port
269 field_port: Port
270 field_account: Compte
270 field_account: Compte
271 field_base_dn: Base DN
271 field_base_dn: Base DN
272 field_attr_login: Attribut Identifiant
272 field_attr_login: Attribut Identifiant
273 field_attr_firstname: Attribut PrΓ©nom
273 field_attr_firstname: Attribut PrΓ©nom
274 field_attr_lastname: Attribut Nom
274 field_attr_lastname: Attribut Nom
275 field_attr_mail: Attribut Email
275 field_attr_mail: Attribut Email
276 field_onthefly: CrΓ©ation des utilisateurs Γ  la volΓ©e
276 field_onthefly: CrΓ©ation des utilisateurs Γ  la volΓ©e
277 field_start_date: DΓ©but
277 field_start_date: DΓ©but
278 field_done_ratio: % rΓ©alisΓ©
278 field_done_ratio: % rΓ©alisΓ©
279 field_auth_source: Mode d'authentification
279 field_auth_source: Mode d'authentification
280 field_hide_mail: Cacher mon adresse mail
280 field_hide_mail: Cacher mon adresse mail
281 field_comments: Commentaire
281 field_comments: Commentaire
282 field_url: URL
282 field_url: URL
283 field_start_page: Page de dΓ©marrage
283 field_start_page: Page de dΓ©marrage
284 field_subproject: Sous-projet
284 field_subproject: Sous-projet
285 field_hours: Heures
285 field_hours: Heures
286 field_activity: ActivitΓ©
286 field_activity: ActivitΓ©
287 field_spent_on: Date
287 field_spent_on: Date
288 field_identifier: Identifiant
288 field_identifier: Identifiant
289 field_is_filter: UtilisΓ© comme filtre
289 field_is_filter: UtilisΓ© comme filtre
290 field_issue_to: Demande liΓ©e
290 field_issue_to: Demande liΓ©e
291 field_delay: Retard
291 field_delay: Retard
292 field_assignable: Demandes assignables Γ  ce rΓ΄le
292 field_assignable: Demandes assignables Γ  ce rΓ΄le
293 field_redirect_existing_links: Rediriger les liens existants
293 field_redirect_existing_links: Rediriger les liens existants
294 field_estimated_hours: Temps estimΓ©
294 field_estimated_hours: Temps estimΓ©
295 field_column_names: Colonnes
295 field_column_names: Colonnes
296 field_time_zone: Fuseau horaire
296 field_time_zone: Fuseau horaire
297 field_searchable: UtilisΓ© pour les recherches
297 field_searchable: UtilisΓ© pour les recherches
298 field_default_value: Valeur par dΓ©faut
298 field_default_value: Valeur par dΓ©faut
299 field_comments_sorting: Afficher les commentaires
299 field_comments_sorting: Afficher les commentaires
300 field_parent_title: Page parent
300 field_parent_title: Page parent
301 field_editable: Modifiable
301 field_editable: Modifiable
302 field_watcher: Observateur
302 field_watcher: Observateur
303 field_identity_url: URL OpenID
303 field_identity_url: URL OpenID
304 field_content: Contenu
304 field_content: Contenu
305 field_group_by: Grouper par
305 field_group_by: Grouper par
306 field_sharing: Partage
306 field_sharing: Partage
307 field_active: Actif
307 field_active: Actif
308 field_parent_issue: TΓ’che parente
308 field_parent_issue: TΓ’che parente
309 field_visible: Visible
309 field_visible: Visible
310 field_warn_on_leaving_unsaved: "M'avertir lorsque je quitte une page contenant du texte non sauvegardΓ©"
310 field_warn_on_leaving_unsaved: "M'avertir lorsque je quitte une page contenant du texte non sauvegardΓ©"
311 field_issues_visibility: VisibilitΓ© des demandes
311
312
312 setting_app_title: Titre de l'application
313 setting_app_title: Titre de l'application
313 setting_app_subtitle: Sous-titre de l'application
314 setting_app_subtitle: Sous-titre de l'application
314 setting_welcome_text: Texte d'accueil
315 setting_welcome_text: Texte d'accueil
315 setting_default_language: Langue par dΓ©faut
316 setting_default_language: Langue par dΓ©faut
316 setting_login_required: Authentification obligatoire
317 setting_login_required: Authentification obligatoire
317 setting_self_registration: Inscription des nouveaux utilisateurs
318 setting_self_registration: Inscription des nouveaux utilisateurs
318 setting_attachment_max_size: Taille max des fichiers
319 setting_attachment_max_size: Taille max des fichiers
319 setting_issues_export_limit: Limite export demandes
320 setting_issues_export_limit: Limite export demandes
320 setting_mail_from: Adresse d'Γ©mission
321 setting_mail_from: Adresse d'Γ©mission
321 setting_bcc_recipients: Destinataires en copie cachΓ©e (cci)
322 setting_bcc_recipients: Destinataires en copie cachΓ©e (cci)
322 setting_plain_text_mail: Mail texte brut (non HTML)
323 setting_plain_text_mail: Mail texte brut (non HTML)
323 setting_host_name: Nom d'hΓ΄te et chemin
324 setting_host_name: Nom d'hΓ΄te et chemin
324 setting_text_formatting: Formatage du texte
325 setting_text_formatting: Formatage du texte
325 setting_wiki_compression: Compression historique wiki
326 setting_wiki_compression: Compression historique wiki
326 setting_feeds_limit: Limite du contenu des flux RSS
327 setting_feeds_limit: Limite du contenu des flux RSS
327 setting_default_projects_public: DΓ©finir les nouveaux projets comme publics par dΓ©faut
328 setting_default_projects_public: DΓ©finir les nouveaux projets comme publics par dΓ©faut
328 setting_autofetch_changesets: RΓ©cupΓ©ration auto. des commits
329 setting_autofetch_changesets: RΓ©cupΓ©ration auto. des commits
329 setting_sys_api_enabled: Activer les WS pour la gestion des dΓ©pΓ΄ts
330 setting_sys_api_enabled: Activer les WS pour la gestion des dΓ©pΓ΄ts
330 setting_commit_ref_keywords: Mots-clΓ©s de rΓ©fΓ©rencement
331 setting_commit_ref_keywords: Mots-clΓ©s de rΓ©fΓ©rencement
331 setting_commit_fix_keywords: Mots-clΓ©s de rΓ©solution
332 setting_commit_fix_keywords: Mots-clΓ©s de rΓ©solution
332 setting_autologin: Autologin
333 setting_autologin: Autologin
333 setting_date_format: Format de date
334 setting_date_format: Format de date
334 setting_time_format: Format d'heure
335 setting_time_format: Format d'heure
335 setting_cross_project_issue_relations: Autoriser les relations entre demandes de diffΓ©rents projets
336 setting_cross_project_issue_relations: Autoriser les relations entre demandes de diffΓ©rents projets
336 setting_issue_list_default_columns: Colonnes affichΓ©es par dΓ©faut sur la liste des demandes
337 setting_issue_list_default_columns: Colonnes affichΓ©es par dΓ©faut sur la liste des demandes
337 setting_repositories_encodings: Encodages des dΓ©pΓ΄ts
338 setting_repositories_encodings: Encodages des dΓ©pΓ΄ts
338 setting_commit_logs_encoding: Encodage des messages de commit
339 setting_commit_logs_encoding: Encodage des messages de commit
339 setting_emails_footer: Pied-de-page des emails
340 setting_emails_footer: Pied-de-page des emails
340 setting_protocol: Protocole
341 setting_protocol: Protocole
341 setting_per_page_options: Options d'objets affichΓ©s par page
342 setting_per_page_options: Options d'objets affichΓ©s par page
342 setting_user_format: Format d'affichage des utilisateurs
343 setting_user_format: Format d'affichage des utilisateurs
343 setting_activity_days_default: Nombre de jours affichΓ©s sur l'activitΓ© des projets
344 setting_activity_days_default: Nombre de jours affichΓ©s sur l'activitΓ© des projets
344 setting_display_subprojects_issues: Afficher par dΓ©faut les demandes des sous-projets sur les projets principaux
345 setting_display_subprojects_issues: Afficher par dΓ©faut les demandes des sous-projets sur les projets principaux
345 setting_enabled_scm: SCM activΓ©s
346 setting_enabled_scm: SCM activΓ©s
346 setting_mail_handler_body_delimiters: "Tronquer les emails après l'une de ces lignes"
347 setting_mail_handler_body_delimiters: "Tronquer les emails après l'une de ces lignes"
347 setting_mail_handler_api_enabled: "Activer le WS pour la rΓ©ception d'emails"
348 setting_mail_handler_api_enabled: "Activer le WS pour la rΓ©ception d'emails"
348 setting_mail_handler_api_key: ClΓ© de protection de l'API
349 setting_mail_handler_api_key: ClΓ© de protection de l'API
349 setting_sequential_project_identifiers: GΓ©nΓ©rer des identifiants de projet sΓ©quentiels
350 setting_sequential_project_identifiers: GΓ©nΓ©rer des identifiants de projet sΓ©quentiels
350 setting_gravatar_enabled: Afficher les Gravatar des utilisateurs
351 setting_gravatar_enabled: Afficher les Gravatar des utilisateurs
351 setting_diff_max_lines_displayed: Nombre maximum de lignes de diff affichΓ©es
352 setting_diff_max_lines_displayed: Nombre maximum de lignes de diff affichΓ©es
352 setting_file_max_size_displayed: Taille maximum des fichiers texte affichΓ©s en ligne
353 setting_file_max_size_displayed: Taille maximum des fichiers texte affichΓ©s en ligne
353 setting_repository_log_display_limit: "Nombre maximum de rΓ©visions affichΓ©es sur l'historique d'un fichier"
354 setting_repository_log_display_limit: "Nombre maximum de rΓ©visions affichΓ©es sur l'historique d'un fichier"
354 setting_openid: "Autoriser l'authentification et l'enregistrement OpenID"
355 setting_openid: "Autoriser l'authentification et l'enregistrement OpenID"
355 setting_password_min_length: Longueur minimum des mots de passe
356 setting_password_min_length: Longueur minimum des mots de passe
356 setting_new_project_user_role_id: RΓ΄le donnΓ© Γ  un utilisateur non-administrateur qui crΓ©e un projet
357 setting_new_project_user_role_id: RΓ΄le donnΓ© Γ  un utilisateur non-administrateur qui crΓ©e un projet
357 setting_default_projects_modules: Modules activΓ©s par dΓ©faut pour les nouveaux projets
358 setting_default_projects_modules: Modules activΓ©s par dΓ©faut pour les nouveaux projets
358 setting_issue_done_ratio: Calcul de l'avancement des demandes
359 setting_issue_done_ratio: Calcul de l'avancement des demandes
359 setting_issue_done_ratio_issue_status: Utiliser le statut
360 setting_issue_done_ratio_issue_status: Utiliser le statut
360 setting_issue_done_ratio_issue_field: 'Utiliser le champ % effectuΓ©'
361 setting_issue_done_ratio_issue_field: 'Utiliser le champ % effectuΓ©'
361 setting_rest_api_enabled: Activer l'API REST
362 setting_rest_api_enabled: Activer l'API REST
362 setting_gravatar_default: Image Gravatar par dΓ©faut
363 setting_gravatar_default: Image Gravatar par dΓ©faut
363 setting_start_of_week: Jour de dΓ©but des calendriers
364 setting_start_of_week: Jour de dΓ©but des calendriers
364 setting_cache_formatted_text: Mettre en cache le texte formatΓ©
365 setting_cache_formatted_text: Mettre en cache le texte formatΓ©
365 setting_commit_logtime_enabled: Permettre la saisie de temps
366 setting_commit_logtime_enabled: Permettre la saisie de temps
366 setting_commit_logtime_activity_id: ActivitΓ© pour le temps saisi
367 setting_commit_logtime_activity_id: ActivitΓ© pour le temps saisi
367 setting_gantt_items_limit: Nombre maximum d'Γ©lΓ©ments affichΓ©s sur le gantt
368 setting_gantt_items_limit: Nombre maximum d'Γ©lΓ©ments affichΓ©s sur le gantt
368
369
369 permission_add_project: CrΓ©er un projet
370 permission_add_project: CrΓ©er un projet
370 permission_add_subprojects: CrΓ©er des sous-projets
371 permission_add_subprojects: CrΓ©er des sous-projets
371 permission_edit_project: Modifier le projet
372 permission_edit_project: Modifier le projet
372 permission_select_project_modules: Choisir les modules
373 permission_select_project_modules: Choisir les modules
373 permission_manage_members: GΓ©rer les membres
374 permission_manage_members: GΓ©rer les membres
374 permission_manage_versions: GΓ©rer les versions
375 permission_manage_versions: GΓ©rer les versions
375 permission_manage_categories: GΓ©rer les catΓ©gories de demandes
376 permission_manage_categories: GΓ©rer les catΓ©gories de demandes
376 permission_view_issues: Voir les demandes
377 permission_view_issues: Voir les demandes
377 permission_add_issues: CrΓ©er des demandes
378 permission_add_issues: CrΓ©er des demandes
378 permission_edit_issues: Modifier les demandes
379 permission_edit_issues: Modifier les demandes
379 permission_manage_issue_relations: GΓ©rer les relations
380 permission_manage_issue_relations: GΓ©rer les relations
380 permission_add_issue_notes: Ajouter des notes
381 permission_add_issue_notes: Ajouter des notes
381 permission_edit_issue_notes: Modifier les notes
382 permission_edit_issue_notes: Modifier les notes
382 permission_edit_own_issue_notes: Modifier ses propres notes
383 permission_edit_own_issue_notes: Modifier ses propres notes
383 permission_move_issues: DΓ©placer les demandes
384 permission_move_issues: DΓ©placer les demandes
384 permission_delete_issues: Supprimer les demandes
385 permission_delete_issues: Supprimer les demandes
385 permission_manage_public_queries: GΓ©rer les requΓͺtes publiques
386 permission_manage_public_queries: GΓ©rer les requΓͺtes publiques
386 permission_save_queries: Sauvegarder les requΓͺtes
387 permission_save_queries: Sauvegarder les requΓͺtes
387 permission_view_gantt: Voir le gantt
388 permission_view_gantt: Voir le gantt
388 permission_view_calendar: Voir le calendrier
389 permission_view_calendar: Voir le calendrier
389 permission_view_issue_watchers: Voir la liste des observateurs
390 permission_view_issue_watchers: Voir la liste des observateurs
390 permission_add_issue_watchers: Ajouter des observateurs
391 permission_add_issue_watchers: Ajouter des observateurs
391 permission_delete_issue_watchers: Supprimer des observateurs
392 permission_delete_issue_watchers: Supprimer des observateurs
392 permission_log_time: Saisir le temps passΓ©
393 permission_log_time: Saisir le temps passΓ©
393 permission_view_time_entries: Voir le temps passΓ©
394 permission_view_time_entries: Voir le temps passΓ©
394 permission_edit_time_entries: Modifier les temps passΓ©s
395 permission_edit_time_entries: Modifier les temps passΓ©s
395 permission_edit_own_time_entries: Modifier son propre temps passΓ©
396 permission_edit_own_time_entries: Modifier son propre temps passΓ©
396 permission_manage_news: GΓ©rer les annonces
397 permission_manage_news: GΓ©rer les annonces
397 permission_comment_news: Commenter les annonces
398 permission_comment_news: Commenter les annonces
398 permission_manage_documents: GΓ©rer les documents
399 permission_manage_documents: GΓ©rer les documents
399 permission_view_documents: Voir les documents
400 permission_view_documents: Voir les documents
400 permission_manage_files: GΓ©rer les fichiers
401 permission_manage_files: GΓ©rer les fichiers
401 permission_view_files: Voir les fichiers
402 permission_view_files: Voir les fichiers
402 permission_manage_wiki: GΓ©rer le wiki
403 permission_manage_wiki: GΓ©rer le wiki
403 permission_rename_wiki_pages: Renommer les pages
404 permission_rename_wiki_pages: Renommer les pages
404 permission_delete_wiki_pages: Supprimer les pages
405 permission_delete_wiki_pages: Supprimer les pages
405 permission_view_wiki_pages: Voir le wiki
406 permission_view_wiki_pages: Voir le wiki
406 permission_view_wiki_edits: "Voir l'historique des modifications"
407 permission_view_wiki_edits: "Voir l'historique des modifications"
407 permission_edit_wiki_pages: Modifier les pages
408 permission_edit_wiki_pages: Modifier les pages
408 permission_delete_wiki_pages_attachments: Supprimer les fichiers joints
409 permission_delete_wiki_pages_attachments: Supprimer les fichiers joints
409 permission_protect_wiki_pages: ProtΓ©ger les pages
410 permission_protect_wiki_pages: ProtΓ©ger les pages
410 permission_manage_repository: GΓ©rer le dΓ©pΓ΄t de sources
411 permission_manage_repository: GΓ©rer le dΓ©pΓ΄t de sources
411 permission_browse_repository: Parcourir les sources
412 permission_browse_repository: Parcourir les sources
412 permission_view_changesets: Voir les rΓ©visions
413 permission_view_changesets: Voir les rΓ©visions
413 permission_commit_access: Droit de commit
414 permission_commit_access: Droit de commit
414 permission_manage_boards: GΓ©rer les forums
415 permission_manage_boards: GΓ©rer les forums
415 permission_view_messages: Voir les messages
416 permission_view_messages: Voir les messages
416 permission_add_messages: Poster un message
417 permission_add_messages: Poster un message
417 permission_edit_messages: Modifier les messages
418 permission_edit_messages: Modifier les messages
418 permission_edit_own_messages: Modifier ses propres messages
419 permission_edit_own_messages: Modifier ses propres messages
419 permission_delete_messages: Supprimer les messages
420 permission_delete_messages: Supprimer les messages
420 permission_delete_own_messages: Supprimer ses propres messages
421 permission_delete_own_messages: Supprimer ses propres messages
421 permission_export_wiki_pages: Exporter les pages
422 permission_export_wiki_pages: Exporter les pages
422 permission_manage_project_activities: GΓ©rer les activitΓ©s
423 permission_manage_project_activities: GΓ©rer les activitΓ©s
423 permission_manage_subtasks: GΓ©rer les sous-tΓ’ches
424 permission_manage_subtasks: GΓ©rer les sous-tΓ’ches
424
425
425 project_module_issue_tracking: Suivi des demandes
426 project_module_issue_tracking: Suivi des demandes
426 project_module_time_tracking: Suivi du temps passΓ©
427 project_module_time_tracking: Suivi du temps passΓ©
427 project_module_news: Publication d'annonces
428 project_module_news: Publication d'annonces
428 project_module_documents: Publication de documents
429 project_module_documents: Publication de documents
429 project_module_files: Publication de fichiers
430 project_module_files: Publication de fichiers
430 project_module_wiki: Wiki
431 project_module_wiki: Wiki
431 project_module_repository: DΓ©pΓ΄t de sources
432 project_module_repository: DΓ©pΓ΄t de sources
432 project_module_boards: Forums de discussion
433 project_module_boards: Forums de discussion
433
434
434 label_user: Utilisateur
435 label_user: Utilisateur
435 label_user_plural: Utilisateurs
436 label_user_plural: Utilisateurs
436 label_user_new: Nouvel utilisateur
437 label_user_new: Nouvel utilisateur
437 label_user_anonymous: Anonyme
438 label_user_anonymous: Anonyme
438 label_project: Projet
439 label_project: Projet
439 label_project_new: Nouveau projet
440 label_project_new: Nouveau projet
440 label_project_plural: Projets
441 label_project_plural: Projets
441 label_x_projects:
442 label_x_projects:
442 zero: aucun projet
443 zero: aucun projet
443 one: un projet
444 one: un projet
444 other: "%{count} projets"
445 other: "%{count} projets"
445 label_project_all: Tous les projets
446 label_project_all: Tous les projets
446 label_project_latest: Derniers projets
447 label_project_latest: Derniers projets
447 label_issue: Demande
448 label_issue: Demande
448 label_issue_new: Nouvelle demande
449 label_issue_new: Nouvelle demande
449 label_issue_plural: Demandes
450 label_issue_plural: Demandes
450 label_issue_view_all: Voir toutes les demandes
451 label_issue_view_all: Voir toutes les demandes
451 label_issue_added: Demande ajoutΓ©e
452 label_issue_added: Demande ajoutΓ©e
452 label_issue_updated: Demande mise Γ  jour
453 label_issue_updated: Demande mise Γ  jour
453 label_issue_note_added: Note ajoutΓ©e
454 label_issue_note_added: Note ajoutΓ©e
454 label_issue_status_updated: Statut changΓ©
455 label_issue_status_updated: Statut changΓ©
455 label_issue_priority_updated: PrioritΓ© changΓ©e
456 label_issue_priority_updated: PrioritΓ© changΓ©e
456 label_issues_by: "Demandes par %{value}"
457 label_issues_by: "Demandes par %{value}"
457 label_document: Document
458 label_document: Document
458 label_document_new: Nouveau document
459 label_document_new: Nouveau document
459 label_document_plural: Documents
460 label_document_plural: Documents
460 label_document_added: Document ajoutΓ©
461 label_document_added: Document ajoutΓ©
461 label_role: RΓ΄le
462 label_role: RΓ΄le
462 label_role_plural: RΓ΄les
463 label_role_plural: RΓ΄les
463 label_role_new: Nouveau rΓ΄le
464 label_role_new: Nouveau rΓ΄le
464 label_role_and_permissions: RΓ΄les et permissions
465 label_role_and_permissions: RΓ΄les et permissions
465 label_role_anonymous: Anonyme
466 label_role_anonymous: Anonyme
466 label_role_non_member: Non membre
467 label_role_non_member: Non membre
467 label_member: Membre
468 label_member: Membre
468 label_member_new: Nouveau membre
469 label_member_new: Nouveau membre
469 label_member_plural: Membres
470 label_member_plural: Membres
470 label_tracker: Tracker
471 label_tracker: Tracker
471 label_tracker_plural: Trackers
472 label_tracker_plural: Trackers
472 label_tracker_new: Nouveau tracker
473 label_tracker_new: Nouveau tracker
473 label_workflow: Workflow
474 label_workflow: Workflow
474 label_issue_status: Statut de demandes
475 label_issue_status: Statut de demandes
475 label_issue_status_plural: Statuts de demandes
476 label_issue_status_plural: Statuts de demandes
476 label_issue_status_new: Nouveau statut
477 label_issue_status_new: Nouveau statut
477 label_issue_category: CatΓ©gorie de demandes
478 label_issue_category: CatΓ©gorie de demandes
478 label_issue_category_plural: CatΓ©gories de demandes
479 label_issue_category_plural: CatΓ©gories de demandes
479 label_issue_category_new: Nouvelle catΓ©gorie
480 label_issue_category_new: Nouvelle catΓ©gorie
480 label_custom_field: Champ personnalisΓ©
481 label_custom_field: Champ personnalisΓ©
481 label_custom_field_plural: Champs personnalisΓ©s
482 label_custom_field_plural: Champs personnalisΓ©s
482 label_custom_field_new: Nouveau champ personnalisΓ©
483 label_custom_field_new: Nouveau champ personnalisΓ©
483 label_enumerations: Listes de valeurs
484 label_enumerations: Listes de valeurs
484 label_enumeration_new: Nouvelle valeur
485 label_enumeration_new: Nouvelle valeur
485 label_information: Information
486 label_information: Information
486 label_information_plural: Informations
487 label_information_plural: Informations
487 label_please_login: Identification
488 label_please_login: Identification
488 label_register: S'enregistrer
489 label_register: S'enregistrer
489 label_login_with_open_id_option: S'authentifier avec OpenID
490 label_login_with_open_id_option: S'authentifier avec OpenID
490 label_password_lost: Mot de passe perdu
491 label_password_lost: Mot de passe perdu
491 label_home: Accueil
492 label_home: Accueil
492 label_my_page: Ma page
493 label_my_page: Ma page
493 label_my_account: Mon compte
494 label_my_account: Mon compte
494 label_my_projects: Mes projets
495 label_my_projects: Mes projets
495 label_my_page_block: Blocs disponibles
496 label_my_page_block: Blocs disponibles
496 label_administration: Administration
497 label_administration: Administration
497 label_login: Connexion
498 label_login: Connexion
498 label_logout: DΓ©connexion
499 label_logout: DΓ©connexion
499 label_help: Aide
500 label_help: Aide
500 label_reported_issues: "Demandes soumises "
501 label_reported_issues: "Demandes soumises "
501 label_assigned_to_me_issues: Demandes qui me sont assignΓ©es
502 label_assigned_to_me_issues: Demandes qui me sont assignΓ©es
502 label_last_login: "Dernière connexion "
503 label_last_login: "Dernière connexion "
503 label_registered_on: "Inscrit le "
504 label_registered_on: "Inscrit le "
504 label_activity: ActivitΓ©
505 label_activity: ActivitΓ©
505 label_overall_activity: ActivitΓ© globale
506 label_overall_activity: ActivitΓ© globale
506 label_user_activity: "ActivitΓ© de %{value}"
507 label_user_activity: "ActivitΓ© de %{value}"
507 label_new: Nouveau
508 label_new: Nouveau
508 label_logged_as: ConnectΓ© en tant que
509 label_logged_as: ConnectΓ© en tant que
509 label_environment: Environnement
510 label_environment: Environnement
510 label_authentication: Authentification
511 label_authentication: Authentification
511 label_auth_source: Mode d'authentification
512 label_auth_source: Mode d'authentification
512 label_auth_source_new: Nouveau mode d'authentification
513 label_auth_source_new: Nouveau mode d'authentification
513 label_auth_source_plural: Modes d'authentification
514 label_auth_source_plural: Modes d'authentification
514 label_subproject_plural: Sous-projets
515 label_subproject_plural: Sous-projets
515 label_subproject_new: Nouveau sous-projet
516 label_subproject_new: Nouveau sous-projet
516 label_and_its_subprojects: "%{value} et ses sous-projets"
517 label_and_its_subprojects: "%{value} et ses sous-projets"
517 label_min_max_length: Longueurs mini - maxi
518 label_min_max_length: Longueurs mini - maxi
518 label_list: Liste
519 label_list: Liste
519 label_date: Date
520 label_date: Date
520 label_integer: Entier
521 label_integer: Entier
521 label_float: Nombre dΓ©cimal
522 label_float: Nombre dΓ©cimal
522 label_boolean: BoolΓ©en
523 label_boolean: BoolΓ©en
523 label_string: Texte
524 label_string: Texte
524 label_text: Texte long
525 label_text: Texte long
525 label_attribute: Attribut
526 label_attribute: Attribut
526 label_attribute_plural: Attributs
527 label_attribute_plural: Attributs
527 label_download: "%{count} tΓ©lΓ©chargement"
528 label_download: "%{count} tΓ©lΓ©chargement"
528 label_download_plural: "%{count} tΓ©lΓ©chargements"
529 label_download_plural: "%{count} tΓ©lΓ©chargements"
529 label_no_data: Aucune donnΓ©e Γ  afficher
530 label_no_data: Aucune donnΓ©e Γ  afficher
530 label_change_status: Changer le statut
531 label_change_status: Changer le statut
531 label_history: Historique
532 label_history: Historique
532 label_attachment: Fichier
533 label_attachment: Fichier
533 label_attachment_new: Nouveau fichier
534 label_attachment_new: Nouveau fichier
534 label_attachment_delete: Supprimer le fichier
535 label_attachment_delete: Supprimer le fichier
535 label_attachment_plural: Fichiers
536 label_attachment_plural: Fichiers
536 label_file_added: Fichier ajoutΓ©
537 label_file_added: Fichier ajoutΓ©
537 label_report: Rapport
538 label_report: Rapport
538 label_report_plural: Rapports
539 label_report_plural: Rapports
539 label_news: Annonce
540 label_news: Annonce
540 label_news_new: Nouvelle annonce
541 label_news_new: Nouvelle annonce
541 label_news_plural: Annonces
542 label_news_plural: Annonces
542 label_news_latest: Dernières annonces
543 label_news_latest: Dernières annonces
543 label_news_view_all: Voir toutes les annonces
544 label_news_view_all: Voir toutes les annonces
544 label_news_added: Annonce ajoutΓ©e
545 label_news_added: Annonce ajoutΓ©e
545 label_news_comment_added: Commentaire ajoutΓ© Γ  une annonce
546 label_news_comment_added: Commentaire ajoutΓ© Γ  une annonce
546 label_settings: Configuration
547 label_settings: Configuration
547 label_overview: AperΓ§u
548 label_overview: AperΓ§u
548 label_version: Version
549 label_version: Version
549 label_version_new: Nouvelle version
550 label_version_new: Nouvelle version
550 label_version_plural: Versions
551 label_version_plural: Versions
551 label_confirmation: Confirmation
552 label_confirmation: Confirmation
552 label_export_to: 'Formats disponibles :'
553 label_export_to: 'Formats disponibles :'
553 label_read: Lire...
554 label_read: Lire...
554 label_public_projects: Projets publics
555 label_public_projects: Projets publics
555 label_open_issues: ouvert
556 label_open_issues: ouvert
556 label_open_issues_plural: ouverts
557 label_open_issues_plural: ouverts
557 label_closed_issues: fermΓ©
558 label_closed_issues: fermΓ©
558 label_closed_issues_plural: fermΓ©s
559 label_closed_issues_plural: fermΓ©s
559 label_x_open_issues_abbr_on_total:
560 label_x_open_issues_abbr_on_total:
560 zero: 0 ouvert sur %{total}
561 zero: 0 ouvert sur %{total}
561 one: 1 ouvert sur %{total}
562 one: 1 ouvert sur %{total}
562 other: "%{count} ouverts sur %{total}"
563 other: "%{count} ouverts sur %{total}"
563 label_x_open_issues_abbr:
564 label_x_open_issues_abbr:
564 zero: 0 ouvert
565 zero: 0 ouvert
565 one: 1 ouvert
566 one: 1 ouvert
566 other: "%{count} ouverts"
567 other: "%{count} ouverts"
567 label_x_closed_issues_abbr:
568 label_x_closed_issues_abbr:
568 zero: 0 fermΓ©
569 zero: 0 fermΓ©
569 one: 1 fermΓ©
570 one: 1 fermΓ©
570 other: "%{count} fermΓ©s"
571 other: "%{count} fermΓ©s"
571 label_total: Total
572 label_total: Total
572 label_permissions: Permissions
573 label_permissions: Permissions
573 label_current_status: Statut actuel
574 label_current_status: Statut actuel
574 label_new_statuses_allowed: Nouveaux statuts autorisΓ©s
575 label_new_statuses_allowed: Nouveaux statuts autorisΓ©s
575 label_all: tous
576 label_all: tous
576 label_none: aucun
577 label_none: aucun
577 label_nobody: personne
578 label_nobody: personne
578 label_next: Suivant
579 label_next: Suivant
579 label_previous: PrΓ©cΓ©dent
580 label_previous: PrΓ©cΓ©dent
580 label_used_by: UtilisΓ© par
581 label_used_by: UtilisΓ© par
581 label_details: DΓ©tails
582 label_details: DΓ©tails
582 label_add_note: Ajouter une note
583 label_add_note: Ajouter une note
583 label_per_page: Par page
584 label_per_page: Par page
584 label_calendar: Calendrier
585 label_calendar: Calendrier
585 label_months_from: mois depuis
586 label_months_from: mois depuis
586 label_gantt: Gantt
587 label_gantt: Gantt
587 label_internal: Interne
588 label_internal: Interne
588 label_last_changes: "%{count} derniers changements"
589 label_last_changes: "%{count} derniers changements"
589 label_change_view_all: Voir tous les changements
590 label_change_view_all: Voir tous les changements
590 label_personalize_page: Personnaliser cette page
591 label_personalize_page: Personnaliser cette page
591 label_comment: Commentaire
592 label_comment: Commentaire
592 label_comment_plural: Commentaires
593 label_comment_plural: Commentaires
593 label_x_comments:
594 label_x_comments:
594 zero: aucun commentaire
595 zero: aucun commentaire
595 one: un commentaire
596 one: un commentaire
596 other: "%{count} commentaires"
597 other: "%{count} commentaires"
597 label_comment_add: Ajouter un commentaire
598 label_comment_add: Ajouter un commentaire
598 label_comment_added: Commentaire ajoutΓ©
599 label_comment_added: Commentaire ajoutΓ©
599 label_comment_delete: Supprimer les commentaires
600 label_comment_delete: Supprimer les commentaires
600 label_query: Rapport personnalisΓ©
601 label_query: Rapport personnalisΓ©
601 label_query_plural: Rapports personnalisΓ©s
602 label_query_plural: Rapports personnalisΓ©s
602 label_query_new: Nouveau rapport
603 label_query_new: Nouveau rapport
603 label_my_queries: Mes rapports personnalisΓ©s
604 label_my_queries: Mes rapports personnalisΓ©s
604 label_filter_add: "Ajouter le filtre "
605 label_filter_add: "Ajouter le filtre "
605 label_filter_plural: Filtres
606 label_filter_plural: Filtres
606 label_equals: Γ©gal
607 label_equals: Γ©gal
607 label_not_equals: diffΓ©rent
608 label_not_equals: diffΓ©rent
608 label_in_less_than: dans moins de
609 label_in_less_than: dans moins de
609 label_in_more_than: dans plus de
610 label_in_more_than: dans plus de
610 label_in: dans
611 label_in: dans
611 label_today: aujourd'hui
612 label_today: aujourd'hui
612 label_all_time: toute la pΓ©riode
613 label_all_time: toute la pΓ©riode
613 label_yesterday: hier
614 label_yesterday: hier
614 label_this_week: cette semaine
615 label_this_week: cette semaine
615 label_last_week: la semaine dernière
616 label_last_week: la semaine dernière
616 label_last_n_days: "les %{count} derniers jours"
617 label_last_n_days: "les %{count} derniers jours"
617 label_this_month: ce mois-ci
618 label_this_month: ce mois-ci
618 label_last_month: le mois dernier
619 label_last_month: le mois dernier
619 label_this_year: cette annΓ©e
620 label_this_year: cette annΓ©e
620 label_date_range: PΓ©riode
621 label_date_range: PΓ©riode
621 label_less_than_ago: il y a moins de
622 label_less_than_ago: il y a moins de
622 label_more_than_ago: il y a plus de
623 label_more_than_ago: il y a plus de
623 label_ago: il y a
624 label_ago: il y a
624 label_contains: contient
625 label_contains: contient
625 label_not_contains: ne contient pas
626 label_not_contains: ne contient pas
626 label_day_plural: jours
627 label_day_plural: jours
627 label_repository: DΓ©pΓ΄t
628 label_repository: DΓ©pΓ΄t
628 label_repository_plural: DΓ©pΓ΄ts
629 label_repository_plural: DΓ©pΓ΄ts
629 label_browse: Parcourir
630 label_browse: Parcourir
630 label_modification: "%{count} modification"
631 label_modification: "%{count} modification"
631 label_modification_plural: "%{count} modifications"
632 label_modification_plural: "%{count} modifications"
632 label_revision: "RΓ©vision "
633 label_revision: "RΓ©vision "
633 label_revision_plural: RΓ©visions
634 label_revision_plural: RΓ©visions
634 label_associated_revisions: RΓ©visions associΓ©es
635 label_associated_revisions: RΓ©visions associΓ©es
635 label_added: ajoutΓ©
636 label_added: ajoutΓ©
636 label_modified: modifiΓ©
637 label_modified: modifiΓ©
637 label_copied: copiΓ©
638 label_copied: copiΓ©
638 label_renamed: renommΓ©
639 label_renamed: renommΓ©
639 label_deleted: supprimΓ©
640 label_deleted: supprimΓ©
640 label_latest_revision: Dernière révision
641 label_latest_revision: Dernière révision
641 label_latest_revision_plural: Dernières révisions
642 label_latest_revision_plural: Dernières révisions
642 label_view_revisions: Voir les rΓ©visions
643 label_view_revisions: Voir les rΓ©visions
643 label_max_size: Taille maximale
644 label_max_size: Taille maximale
644 label_sort_highest: Remonter en premier
645 label_sort_highest: Remonter en premier
645 label_sort_higher: Remonter
646 label_sort_higher: Remonter
646 label_sort_lower: Descendre
647 label_sort_lower: Descendre
647 label_sort_lowest: Descendre en dernier
648 label_sort_lowest: Descendre en dernier
648 label_roadmap: Roadmap
649 label_roadmap: Roadmap
649 label_roadmap_due_in: "Γ‰chΓ©ance dans %{value}"
650 label_roadmap_due_in: "Γ‰chΓ©ance dans %{value}"
650 label_roadmap_overdue: "En retard de %{value}"
651 label_roadmap_overdue: "En retard de %{value}"
651 label_roadmap_no_issues: Aucune demande pour cette version
652 label_roadmap_no_issues: Aucune demande pour cette version
652 label_search: "Recherche "
653 label_search: "Recherche "
653 label_result_plural: RΓ©sultats
654 label_result_plural: RΓ©sultats
654 label_all_words: Tous les mots
655 label_all_words: Tous les mots
655 label_wiki: Wiki
656 label_wiki: Wiki
656 label_wiki_edit: RΓ©vision wiki
657 label_wiki_edit: RΓ©vision wiki
657 label_wiki_edit_plural: RΓ©visions wiki
658 label_wiki_edit_plural: RΓ©visions wiki
658 label_wiki_page: Page wiki
659 label_wiki_page: Page wiki
659 label_wiki_page_plural: Pages wiki
660 label_wiki_page_plural: Pages wiki
660 label_index_by_title: Index par titre
661 label_index_by_title: Index par titre
661 label_index_by_date: Index par date
662 label_index_by_date: Index par date
662 label_current_version: Version actuelle
663 label_current_version: Version actuelle
663 label_preview: PrΓ©visualisation
664 label_preview: PrΓ©visualisation
664 label_feed_plural: Flux RSS
665 label_feed_plural: Flux RSS
665 label_changes_details: DΓ©tails de tous les changements
666 label_changes_details: DΓ©tails de tous les changements
666 label_issue_tracking: Suivi des demandes
667 label_issue_tracking: Suivi des demandes
667 label_spent_time: Temps passΓ©
668 label_spent_time: Temps passΓ©
668 label_f_hour: "%{value} heure"
669 label_f_hour: "%{value} heure"
669 label_f_hour_plural: "%{value} heures"
670 label_f_hour_plural: "%{value} heures"
670 label_time_tracking: Suivi du temps
671 label_time_tracking: Suivi du temps
671 label_change_plural: Changements
672 label_change_plural: Changements
672 label_statistics: Statistiques
673 label_statistics: Statistiques
673 label_commits_per_month: Commits par mois
674 label_commits_per_month: Commits par mois
674 label_commits_per_author: Commits par auteur
675 label_commits_per_author: Commits par auteur
675 label_view_diff: Voir les diffΓ©rences
676 label_view_diff: Voir les diffΓ©rences
676 label_diff_inline: en ligne
677 label_diff_inline: en ligne
677 label_diff_side_by_side: cΓ΄te Γ  cΓ΄te
678 label_diff_side_by_side: cΓ΄te Γ  cΓ΄te
678 label_options: Options
679 label_options: Options
679 label_copy_workflow_from: Copier le workflow de
680 label_copy_workflow_from: Copier le workflow de
680 label_permissions_report: Synthèse des permissions
681 label_permissions_report: Synthèse des permissions
681 label_watched_issues: Demandes surveillΓ©es
682 label_watched_issues: Demandes surveillΓ©es
682 label_related_issues: Demandes liΓ©es
683 label_related_issues: Demandes liΓ©es
683 label_applied_status: Statut appliquΓ©
684 label_applied_status: Statut appliquΓ©
684 label_loading: Chargement...
685 label_loading: Chargement...
685 label_relation_new: Nouvelle relation
686 label_relation_new: Nouvelle relation
686 label_relation_delete: Supprimer la relation
687 label_relation_delete: Supprimer la relation
687 label_relates_to: liΓ© Γ 
688 label_relates_to: liΓ© Γ 
688 label_duplicates: duplique
689 label_duplicates: duplique
689 label_duplicated_by: dupliquΓ© par
690 label_duplicated_by: dupliquΓ© par
690 label_blocks: bloque
691 label_blocks: bloque
691 label_blocked_by: bloquΓ© par
692 label_blocked_by: bloquΓ© par
692 label_precedes: précède
693 label_precedes: précède
693 label_follows: suit
694 label_follows: suit
694 label_end_to_start: fin Γ  dΓ©but
695 label_end_to_start: fin Γ  dΓ©but
695 label_end_to_end: fin Γ  fin
696 label_end_to_end: fin Γ  fin
696 label_start_to_start: dΓ©but Γ  dΓ©but
697 label_start_to_start: dΓ©but Γ  dΓ©but
697 label_start_to_end: dΓ©but Γ  fin
698 label_start_to_end: dΓ©but Γ  fin
698 label_stay_logged_in: Rester connectΓ©
699 label_stay_logged_in: Rester connectΓ©
699 label_disabled: dΓ©sactivΓ©
700 label_disabled: dΓ©sactivΓ©
700 label_show_completed_versions: Voir les versions passΓ©es
701 label_show_completed_versions: Voir les versions passΓ©es
701 label_me: moi
702 label_me: moi
702 label_board: Forum
703 label_board: Forum
703 label_board_new: Nouveau forum
704 label_board_new: Nouveau forum
704 label_board_plural: Forums
705 label_board_plural: Forums
705 label_topic_plural: Discussions
706 label_topic_plural: Discussions
706 label_message_plural: Messages
707 label_message_plural: Messages
707 label_message_last: Dernier message
708 label_message_last: Dernier message
708 label_message_new: Nouveau message
709 label_message_new: Nouveau message
709 label_message_posted: Message ajoutΓ©
710 label_message_posted: Message ajoutΓ©
710 label_reply_plural: RΓ©ponses
711 label_reply_plural: RΓ©ponses
711 label_send_information: Envoyer les informations Γ  l'utilisateur
712 label_send_information: Envoyer les informations Γ  l'utilisateur
712 label_year: AnnΓ©e
713 label_year: AnnΓ©e
713 label_month: Mois
714 label_month: Mois
714 label_week: Semaine
715 label_week: Semaine
715 label_date_from: Du
716 label_date_from: Du
716 label_date_to: Au
717 label_date_to: Au
717 label_language_based: BasΓ© sur la langue de l'utilisateur
718 label_language_based: BasΓ© sur la langue de l'utilisateur
718 label_sort_by: "Trier par %{value}"
719 label_sort_by: "Trier par %{value}"
719 label_send_test_email: Envoyer un email de test
720 label_send_test_email: Envoyer un email de test
720 label_feeds_access_key_created_on: "Clé d'accès RSS créée il y a %{value}"
721 label_feeds_access_key_created_on: "Clé d'accès RSS créée il y a %{value}"
721 label_module_plural: Modules
722 label_module_plural: Modules
722 label_added_time_by: "AjoutΓ© par %{author} il y a %{age}"
723 label_added_time_by: "AjoutΓ© par %{author} il y a %{age}"
723 label_updated_time_by: "Mis Γ  jour par %{author} il y a %{age}"
724 label_updated_time_by: "Mis Γ  jour par %{author} il y a %{age}"
724 label_updated_time: "Mis Γ  jour il y a %{value}"
725 label_updated_time: "Mis Γ  jour il y a %{value}"
725 label_jump_to_a_project: Aller Γ  un projet...
726 label_jump_to_a_project: Aller Γ  un projet...
726 label_file_plural: Fichiers
727 label_file_plural: Fichiers
727 label_changeset_plural: RΓ©visions
728 label_changeset_plural: RΓ©visions
728 label_default_columns: Colonnes par dΓ©faut
729 label_default_columns: Colonnes par dΓ©faut
729 label_no_change_option: (Pas de changement)
730 label_no_change_option: (Pas de changement)
730 label_bulk_edit_selected_issues: Modifier les demandes sΓ©lectionnΓ©es
731 label_bulk_edit_selected_issues: Modifier les demandes sΓ©lectionnΓ©es
731 label_theme: Thème
732 label_theme: Thème
732 label_default: DΓ©faut
733 label_default: DΓ©faut
733 label_search_titles_only: Uniquement dans les titres
734 label_search_titles_only: Uniquement dans les titres
734 label_user_mail_option_all: "Pour tous les Γ©vΓ©nements de tous mes projets"
735 label_user_mail_option_all: "Pour tous les Γ©vΓ©nements de tous mes projets"
735 label_user_mail_option_selected: "Pour tous les Γ©vΓ©nements des projets sΓ©lectionnΓ©s..."
736 label_user_mail_option_selected: "Pour tous les Γ©vΓ©nements des projets sΓ©lectionnΓ©s..."
736 label_user_mail_no_self_notified: "Je ne veux pas Γͺtre notifiΓ© des changements que j'effectue"
737 label_user_mail_no_self_notified: "Je ne veux pas Γͺtre notifiΓ© des changements que j'effectue"
737 label_registration_activation_by_email: activation du compte par email
738 label_registration_activation_by_email: activation du compte par email
738 label_registration_manual_activation: activation manuelle du compte
739 label_registration_manual_activation: activation manuelle du compte
739 label_registration_automatic_activation: activation automatique du compte
740 label_registration_automatic_activation: activation automatique du compte
740 label_display_per_page: "Par page : %{value}"
741 label_display_per_page: "Par page : %{value}"
741 label_age: Γ‚ge
742 label_age: Γ‚ge
742 label_change_properties: Changer les propriΓ©tΓ©s
743 label_change_properties: Changer les propriΓ©tΓ©s
743 label_general: GΓ©nΓ©ral
744 label_general: GΓ©nΓ©ral
744 label_more: Plus
745 label_more: Plus
745 label_scm: SCM
746 label_scm: SCM
746 label_plugins: Plugins
747 label_plugins: Plugins
747 label_ldap_authentication: Authentification LDAP
748 label_ldap_authentication: Authentification LDAP
748 label_downloads_abbr: D/L
749 label_downloads_abbr: D/L
749 label_optional_description: Description facultative
750 label_optional_description: Description facultative
750 label_add_another_file: Ajouter un autre fichier
751 label_add_another_file: Ajouter un autre fichier
751 label_preferences: PrΓ©fΓ©rences
752 label_preferences: PrΓ©fΓ©rences
752 label_chronological_order: Dans l'ordre chronologique
753 label_chronological_order: Dans l'ordre chronologique
753 label_reverse_chronological_order: Dans l'ordre chronologique inverse
754 label_reverse_chronological_order: Dans l'ordre chronologique inverse
754 label_planning: Planning
755 label_planning: Planning
755 label_incoming_emails: Emails entrants
756 label_incoming_emails: Emails entrants
756 label_generate_key: GΓ©nΓ©rer une clΓ©
757 label_generate_key: GΓ©nΓ©rer une clΓ©
757 label_issue_watchers: Observateurs
758 label_issue_watchers: Observateurs
758 label_example: Exemple
759 label_example: Exemple
759 label_display: Affichage
760 label_display: Affichage
760 label_sort: Tri
761 label_sort: Tri
761 label_ascending: Croissant
762 label_ascending: Croissant
762 label_descending: DΓ©croissant
763 label_descending: DΓ©croissant
763 label_date_from_to: Du %{start} au %{end}
764 label_date_from_to: Du %{start} au %{end}
764 label_wiki_content_added: Page wiki ajoutΓ©e
765 label_wiki_content_added: Page wiki ajoutΓ©e
765 label_wiki_content_updated: Page wiki mise Γ  jour
766 label_wiki_content_updated: Page wiki mise Γ  jour
766 label_group_plural: Groupes
767 label_group_plural: Groupes
767 label_group: Groupe
768 label_group: Groupe
768 label_group_new: Nouveau groupe
769 label_group_new: Nouveau groupe
769 label_time_entry_plural: Temps passΓ©
770 label_time_entry_plural: Temps passΓ©
770 label_version_sharing_none: Non partagΓ©
771 label_version_sharing_none: Non partagΓ©
771 label_version_sharing_descendants: Avec les sous-projets
772 label_version_sharing_descendants: Avec les sous-projets
772 label_version_sharing_hierarchy: Avec toute la hiΓ©rarchie
773 label_version_sharing_hierarchy: Avec toute la hiΓ©rarchie
773 label_version_sharing_tree: Avec tout l'arbre
774 label_version_sharing_tree: Avec tout l'arbre
774 label_version_sharing_system: Avec tous les projets
775 label_version_sharing_system: Avec tous les projets
775 label_copy_source: Source
776 label_copy_source: Source
776 label_copy_target: Cible
777 label_copy_target: Cible
777 label_copy_same_as_target: Comme la cible
778 label_copy_same_as_target: Comme la cible
778 label_update_issue_done_ratios: Mettre Γ  jour l'avancement des demandes
779 label_update_issue_done_ratios: Mettre Γ  jour l'avancement des demandes
779 label_display_used_statuses_only: N'afficher que les statuts utilisΓ©s dans ce tracker
780 label_display_used_statuses_only: N'afficher que les statuts utilisΓ©s dans ce tracker
780 label_api_access_key: Clé d'accès API
781 label_api_access_key: Clé d'accès API
781 label_api_access_key_created_on: Clé d'accès API créée il y a %{value}
782 label_api_access_key_created_on: Clé d'accès API créée il y a %{value}
782 label_feeds_access_key: Clé d'accès RSS
783 label_feeds_access_key: Clé d'accès RSS
783 label_missing_api_access_key: Clé d'accès API manquante
784 label_missing_api_access_key: Clé d'accès API manquante
784 label_missing_feeds_access_key: Clé d'accès RSS manquante
785 label_missing_feeds_access_key: Clé d'accès RSS manquante
785 label_close_versions: Fermer les versions terminΓ©es
786 label_close_versions: Fermer les versions terminΓ©es
786 label_revision_id: Revision %{value}
787 label_revision_id: Revision %{value}
787 label_profile: Profil
788 label_profile: Profil
788 label_subtask_plural: Sous-tΓ’ches
789 label_subtask_plural: Sous-tΓ’ches
789 label_project_copy_notifications: Envoyer les notifications durant la copie du projet
790 label_project_copy_notifications: Envoyer les notifications durant la copie du projet
790 label_principal_search: "Rechercher un utilisateur ou un groupe :"
791 label_principal_search: "Rechercher un utilisateur ou un groupe :"
791 label_user_search: "Rechercher un utilisateur :"
792 label_user_search: "Rechercher un utilisateur :"
792 label_additional_workflow_transitions_for_author: Autorisations supplémentaires lorsque l'utilisateur a créé la demande
793 label_additional_workflow_transitions_for_author: Autorisations supplémentaires lorsque l'utilisateur a créé la demande
793 label_additional_workflow_transitions_for_assignee: Autorisations supplΓ©mentaires lorsque la demande est assignΓ©e Γ  l'utilisateur
794 label_additional_workflow_transitions_for_assignee: Autorisations supplΓ©mentaires lorsque la demande est assignΓ©e Γ  l'utilisateur
795 label_issues_visibility_all: Toutes les demandes
796 label_issues_visibility_own: Demandes créées par ou assignées à l'utilisateur
794
797
795 button_login: Connexion
798 button_login: Connexion
796 button_submit: Soumettre
799 button_submit: Soumettre
797 button_save: Sauvegarder
800 button_save: Sauvegarder
798 button_check_all: Tout cocher
801 button_check_all: Tout cocher
799 button_uncheck_all: Tout dΓ©cocher
802 button_uncheck_all: Tout dΓ©cocher
800 button_collapse_all: Plier tout
803 button_collapse_all: Plier tout
801 button_expand_all: DΓ©plier tout
804 button_expand_all: DΓ©plier tout
802 button_delete: Supprimer
805 button_delete: Supprimer
803 button_create: CrΓ©er
806 button_create: CrΓ©er
804 button_create_and_continue: CrΓ©er et continuer
807 button_create_and_continue: CrΓ©er et continuer
805 button_test: Tester
808 button_test: Tester
806 button_edit: Modifier
809 button_edit: Modifier
807 button_add: Ajouter
810 button_add: Ajouter
808 button_change: Changer
811 button_change: Changer
809 button_apply: Appliquer
812 button_apply: Appliquer
810 button_clear: Effacer
813 button_clear: Effacer
811 button_lock: Verrouiller
814 button_lock: Verrouiller
812 button_unlock: DΓ©verrouiller
815 button_unlock: DΓ©verrouiller
813 button_download: TΓ©lΓ©charger
816 button_download: TΓ©lΓ©charger
814 button_list: Lister
817 button_list: Lister
815 button_view: Voir
818 button_view: Voir
816 button_move: DΓ©placer
819 button_move: DΓ©placer
817 button_move_and_follow: DΓ©placer et suivre
820 button_move_and_follow: DΓ©placer et suivre
818 button_back: Retour
821 button_back: Retour
819 button_cancel: Annuler
822 button_cancel: Annuler
820 button_activate: Activer
823 button_activate: Activer
821 button_sort: Trier
824 button_sort: Trier
822 button_log_time: Saisir temps
825 button_log_time: Saisir temps
823 button_rollback: Revenir Γ  cette version
826 button_rollback: Revenir Γ  cette version
824 button_watch: Surveiller
827 button_watch: Surveiller
825 button_unwatch: Ne plus surveiller
828 button_unwatch: Ne plus surveiller
826 button_reply: RΓ©pondre
829 button_reply: RΓ©pondre
827 button_archive: Archiver
830 button_archive: Archiver
828 button_unarchive: DΓ©sarchiver
831 button_unarchive: DΓ©sarchiver
829 button_reset: RΓ©initialiser
832 button_reset: RΓ©initialiser
830 button_rename: Renommer
833 button_rename: Renommer
831 button_change_password: Changer de mot de passe
834 button_change_password: Changer de mot de passe
832 button_copy: Copier
835 button_copy: Copier
833 button_copy_and_follow: Copier et suivre
836 button_copy_and_follow: Copier et suivre
834 button_annotate: Annoter
837 button_annotate: Annoter
835 button_update: Mettre Γ  jour
838 button_update: Mettre Γ  jour
836 button_configure: Configurer
839 button_configure: Configurer
837 button_quote: Citer
840 button_quote: Citer
838 button_duplicate: Dupliquer
841 button_duplicate: Dupliquer
839 button_show: Afficher
842 button_show: Afficher
840
843
841 status_active: actif
844 status_active: actif
842 status_registered: enregistrΓ©
845 status_registered: enregistrΓ©
843 status_locked: verrouillΓ©
846 status_locked: verrouillΓ©
844
847
845 version_status_open: ouvert
848 version_status_open: ouvert
846 version_status_locked: verrouillΓ©
849 version_status_locked: verrouillΓ©
847 version_status_closed: fermΓ©
850 version_status_closed: fermΓ©
848
851
849 text_select_mail_notifications: Actions pour lesquelles une notification par e-mail est envoyΓ©e
852 text_select_mail_notifications: Actions pour lesquelles une notification par e-mail est envoyΓ©e
850 text_regexp_info: ex. ^[A-Z0-9]+$
853 text_regexp_info: ex. ^[A-Z0-9]+$
851 text_min_max_length_info: 0 pour aucune restriction
854 text_min_max_length_info: 0 pour aucune restriction
852 text_project_destroy_confirmation: Êtes-vous sûr de vouloir supprimer ce projet et toutes ses données ?
855 text_project_destroy_confirmation: Êtes-vous sûr de vouloir supprimer ce projet et toutes ses données ?
853 text_subprojects_destroy_warning: "Ses sous-projets : %{value} seront Γ©galement supprimΓ©s."
856 text_subprojects_destroy_warning: "Ses sous-projets : %{value} seront Γ©galement supprimΓ©s."
854 text_workflow_edit: SΓ©lectionner un tracker et un rΓ΄le pour Γ©diter le workflow
857 text_workflow_edit: SΓ©lectionner un tracker et un rΓ΄le pour Γ©diter le workflow
855 text_are_you_sure: Êtes-vous sûr ?
858 text_are_you_sure: Êtes-vous sûr ?
856 text_tip_issue_begin_day: tΓ’che commenΓ§ant ce jour
859 text_tip_issue_begin_day: tΓ’che commenΓ§ant ce jour
857 text_tip_issue_end_day: tΓ’che finissant ce jour
860 text_tip_issue_end_day: tΓ’che finissant ce jour
858 text_tip_issue_begin_end_day: tΓ’che commenΓ§ant et finissant ce jour
861 text_tip_issue_begin_end_day: tΓ’che commenΓ§ant et finissant ce jour
859 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Γ©.'
862 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Γ©.'
860 text_caracters_maximum: "%{count} caractères maximum."
863 text_caracters_maximum: "%{count} caractères maximum."
861 text_caracters_minimum: "%{count} caractères minimum."
864 text_caracters_minimum: "%{count} caractères minimum."
862 text_length_between: "Longueur comprise entre %{min} et %{max} caractères."
865 text_length_between: "Longueur comprise entre %{min} et %{max} caractères."
863 text_tracker_no_workflow: Aucun worflow n'est dΓ©fini pour ce tracker
866 text_tracker_no_workflow: Aucun worflow n'est dΓ©fini pour ce tracker
864 text_unallowed_characters: Caractères non autorisés
867 text_unallowed_characters: Caractères non autorisés
865 text_comma_separated: Plusieurs valeurs possibles (sΓ©parΓ©es par des virgules).
868 text_comma_separated: Plusieurs valeurs possibles (sΓ©parΓ©es par des virgules).
866 text_line_separated: Plusieurs valeurs possibles (une valeur par ligne).
869 text_line_separated: Plusieurs valeurs possibles (une valeur par ligne).
867 text_issues_ref_in_commit_messages: RΓ©fΓ©rencement et rΓ©solution des demandes dans les commentaires de commits
870 text_issues_ref_in_commit_messages: RΓ©fΓ©rencement et rΓ©solution des demandes dans les commentaires de commits
868 text_issue_added: "La demande %{id} a Γ©tΓ© soumise par %{author}."
871 text_issue_added: "La demande %{id} a Γ©tΓ© soumise par %{author}."
869 text_issue_updated: "La demande %{id} a Γ©tΓ© mise Γ  jour par %{author}."
872 text_issue_updated: "La demande %{id} a Γ©tΓ© mise Γ  jour par %{author}."
870 text_wiki_destroy_confirmation: Etes-vous sΓ»r de vouloir supprimer ce wiki et tout son contenu ?
873 text_wiki_destroy_confirmation: Etes-vous sΓ»r de vouloir supprimer ce wiki et tout son contenu ?
871 text_issue_category_destroy_question: "%{count} demandes sont affectΓ©es Γ  cette catΓ©gorie. Que voulez-vous faire ?"
874 text_issue_category_destroy_question: "%{count} demandes sont affectΓ©es Γ  cette catΓ©gorie. Que voulez-vous faire ?"
872 text_issue_category_destroy_assignments: N'affecter les demandes Γ  aucune autre catΓ©gorie
875 text_issue_category_destroy_assignments: N'affecter les demandes Γ  aucune autre catΓ©gorie
873 text_issue_category_reassign_to: RΓ©affecter les demandes Γ  cette catΓ©gorie
876 text_issue_category_reassign_to: RΓ©affecter les demandes Γ  cette catΓ©gorie
874 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)."
877 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)."
875 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Γ©."
878 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Γ©."
876 text_load_default_configuration: Charger le paramΓ©trage par dΓ©faut
879 text_load_default_configuration: Charger le paramΓ©trage par dΓ©faut
877 text_status_changed_by_changeset: "AppliquΓ© par commit %{value}."
880 text_status_changed_by_changeset: "AppliquΓ© par commit %{value}."
878 text_time_logged_by_changeset: "AppliquΓ© par commit %{value}"
881 text_time_logged_by_changeset: "AppliquΓ© par commit %{value}"
879 text_issues_destroy_confirmation: 'Êtes-vous sûr de vouloir supprimer le(s) demandes(s) selectionnée(s) ?'
882 text_issues_destroy_confirmation: 'Êtes-vous sûr de vouloir supprimer le(s) demandes(s) selectionnée(s) ?'
880 text_select_project_modules: 'SΓ©lectionner les modules Γ  activer pour ce projet :'
883 text_select_project_modules: 'SΓ©lectionner les modules Γ  activer pour ce projet :'
881 text_default_administrator_account_changed: Compte administrateur par dΓ©faut changΓ©
884 text_default_administrator_account_changed: Compte administrateur par dΓ©faut changΓ©
882 text_file_repository_writable: RΓ©pertoire de stockage des fichiers accessible en Γ©criture
885 text_file_repository_writable: RΓ©pertoire de stockage des fichiers accessible en Γ©criture
883 text_plugin_assets_writable: RΓ©pertoire public des plugins accessible en Γ©criture
886 text_plugin_assets_writable: RΓ©pertoire public des plugins accessible en Γ©criture
884 text_rmagick_available: Bibliothèque RMagick présente (optionnelle)
887 text_rmagick_available: Bibliothèque RMagick présente (optionnelle)
885 text_destroy_time_entries_question: "%{hours} heures ont Γ©tΓ© enregistrΓ©es sur les demandes Γ  supprimer. Que voulez-vous faire ?"
888 text_destroy_time_entries_question: "%{hours} heures ont Γ©tΓ© enregistrΓ©es sur les demandes Γ  supprimer. Que voulez-vous faire ?"
886 text_destroy_time_entries: Supprimer les heures
889 text_destroy_time_entries: Supprimer les heures
887 text_assign_time_entries_to_project: Reporter les heures sur le projet
890 text_assign_time_entries_to_project: Reporter les heures sur le projet
888 text_reassign_time_entries: 'Reporter les heures sur cette demande:'
891 text_reassign_time_entries: 'Reporter les heures sur cette demande:'
889 text_user_wrote: "%{value} a Γ©crit :"
892 text_user_wrote: "%{value} a Γ©crit :"
890 text_enumeration_destroy_question: "Cette valeur est affectΓ©e Γ  %{count} objets."
893 text_enumeration_destroy_question: "Cette valeur est affectΓ©e Γ  %{count} objets."
891 text_enumeration_category_reassign_to: 'RΓ©affecter les objets Γ  cette valeur:'
894 text_enumeration_category_reassign_to: 'RΓ©affecter les objets Γ  cette valeur:'
892 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/configuration.yml et redΓ©marrez l'application pour les activer."
895 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/configuration.yml et redΓ©marrez l'application pour les activer."
893 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."
896 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."
894 text_diff_truncated: '... Ce diffΓ©rentiel a Γ©tΓ© tronquΓ© car il excΓ¨de la taille maximale pouvant Γͺtre affichΓ©e.'
897 text_diff_truncated: '... Ce diffΓ©rentiel a Γ©tΓ© tronquΓ© car il excΓ¨de la taille maximale pouvant Γͺtre affichΓ©e.'
895 text_custom_field_possible_values_info: 'Une ligne par valeur'
898 text_custom_field_possible_values_info: 'Une ligne par valeur'
896 text_wiki_page_destroy_question: "Cette page possède %{descendants} sous-page(s) et descendante(s). Que voulez-vous faire ?"
899 text_wiki_page_destroy_question: "Cette page possède %{descendants} sous-page(s) et descendante(s). Que voulez-vous faire ?"
897 text_wiki_page_nullify_children: "Conserver les sous-pages en tant que pages racines"
900 text_wiki_page_nullify_children: "Conserver les sous-pages en tant que pages racines"
898 text_wiki_page_destroy_children: "Supprimer les sous-pages et toutes leurs descedantes"
901 text_wiki_page_destroy_children: "Supprimer les sous-pages et toutes leurs descedantes"
899 text_wiki_page_reassign_children: "RΓ©affecter les sous-pages Γ  cette page"
902 text_wiki_page_reassign_children: "RΓ©affecter les sous-pages Γ  cette page"
900 text_own_membership_delete_confirmation: "Vous allez supprimer tout ou partie de vos permissions sur ce projet et ne serez peut-Γͺtre plus autorisΓ© Γ  modifier ce projet.\nEtes-vous sΓ»r de vouloir continuer ?"
903 text_own_membership_delete_confirmation: "Vous allez supprimer tout ou partie de vos permissions sur ce projet et ne serez peut-Γͺtre plus autorisΓ© Γ  modifier ce projet.\nEtes-vous sΓ»r de vouloir continuer ?"
901 text_warn_on_leaving_unsaved: "Cette page contient du texte non sauvegardΓ© qui sera perdu si vous quittez la page."
904 text_warn_on_leaving_unsaved: "Cette page contient du texte non sauvegardΓ© qui sera perdu si vous quittez la page."
902
905
903 default_role_manager: "Manager "
906 default_role_manager: "Manager "
904 default_role_developer: "DΓ©veloppeur "
907 default_role_developer: "DΓ©veloppeur "
905 default_role_reporter: "Rapporteur "
908 default_role_reporter: "Rapporteur "
906 default_tracker_bug: Anomalie
909 default_tracker_bug: Anomalie
907 default_tracker_feature: Evolution
910 default_tracker_feature: Evolution
908 default_tracker_support: Assistance
911 default_tracker_support: Assistance
909 default_issue_status_new: Nouveau
912 default_issue_status_new: Nouveau
910 default_issue_status_in_progress: En cours
913 default_issue_status_in_progress: En cours
911 default_issue_status_resolved: RΓ©solu
914 default_issue_status_resolved: RΓ©solu
912 default_issue_status_feedback: Commentaire
915 default_issue_status_feedback: Commentaire
913 default_issue_status_closed: FermΓ©
916 default_issue_status_closed: FermΓ©
914 default_issue_status_rejected: RejetΓ©
917 default_issue_status_rejected: RejetΓ©
915 default_doc_category_user: Documentation utilisateur
918 default_doc_category_user: Documentation utilisateur
916 default_doc_category_tech: Documentation technique
919 default_doc_category_tech: Documentation technique
917 default_priority_low: Bas
920 default_priority_low: Bas
918 default_priority_normal: Normal
921 default_priority_normal: Normal
919 default_priority_high: Haut
922 default_priority_high: Haut
920 default_priority_urgent: Urgent
923 default_priority_urgent: Urgent
921 default_priority_immediate: ImmΓ©diat
924 default_priority_immediate: ImmΓ©diat
922 default_activity_design: Conception
925 default_activity_design: Conception
923 default_activity_development: DΓ©veloppement
926 default_activity_development: DΓ©veloppement
924
927
925 enumeration_issue_priorities: PrioritΓ©s des demandes
928 enumeration_issue_priorities: PrioritΓ©s des demandes
926 enumeration_doc_categories: CatΓ©gories des documents
929 enumeration_doc_categories: CatΓ©gories des documents
927 enumeration_activities: ActivitΓ©s (suivi du temps)
930 enumeration_activities: ActivitΓ©s (suivi du temps)
928 label_greater_or_equal: ">="
931 label_greater_or_equal: ">="
929 label_less_or_equal: "<="
932 label_less_or_equal: "<="
930 label_view_all_revisions: Voir toutes les rΓ©visions
933 label_view_all_revisions: Voir toutes les rΓ©visions
931 label_tag: Tag
934 label_tag: Tag
932 label_branch: Branche
935 label_branch: Branche
933 error_no_tracker_in_project: "Aucun tracker n'est associΓ© Γ  ce projet. VΓ©rifier la configuration du projet."
936 error_no_tracker_in_project: "Aucun tracker n'est associΓ© Γ  ce projet. VΓ©rifier la configuration du projet."
934 error_no_default_issue_status: "Aucun statut de demande n'est dΓ©fini par dΓ©faut. VΓ©rifier votre configuration (Administration -> Statuts de demandes)."
937 error_no_default_issue_status: "Aucun statut de demande n'est dΓ©fini par dΓ©faut. VΓ©rifier votre configuration (Administration -> Statuts de demandes)."
935 text_journal_changed: "%{label} changΓ© de %{old} Γ  %{new}"
938 text_journal_changed: "%{label} changΓ© de %{old} Γ  %{new}"
936 text_journal_changed_no_detail: "%{label} mis Γ  jour"
939 text_journal_changed_no_detail: "%{label} mis Γ  jour"
937 text_journal_set_to: "%{label} mis Γ  %{value}"
940 text_journal_set_to: "%{label} mis Γ  %{value}"
938 text_journal_deleted: "%{label} %{old} supprimΓ©"
941 text_journal_deleted: "%{label} %{old} supprimΓ©"
939 text_journal_added: "%{label} %{value} ajoutΓ©"
942 text_journal_added: "%{label} %{value} ajoutΓ©"
940 enumeration_system_activity: Activité système
943 enumeration_system_activity: Activité système
941 label_board_sticky: Sticky
944 label_board_sticky: Sticky
942 label_board_locked: VerrouillΓ©
945 label_board_locked: VerrouillΓ©
943 error_unable_delete_issue_status: Impossible de supprimer le statut de demande
946 error_unable_delete_issue_status: Impossible de supprimer le statut de demande
944 error_can_not_delete_custom_field: Impossible de supprimer le champ personnalisΓ©
947 error_can_not_delete_custom_field: Impossible de supprimer le champ personnalisΓ©
945 error_unable_to_connect: Connexion impossible (%{value})
948 error_unable_to_connect: Connexion impossible (%{value})
946 error_can_not_remove_role: Ce rΓ΄le est utilisΓ© et ne peut pas Γͺtre supprimΓ©.
949 error_can_not_remove_role: Ce rΓ΄le est utilisΓ© et ne peut pas Γͺtre supprimΓ©.
947 error_can_not_delete_tracker: Ce tracker contient des demandes et ne peut pas Γͺtre supprimΓ©.
950 error_can_not_delete_tracker: Ce tracker contient des demandes et ne peut pas Γͺtre supprimΓ©.
948 field_principal: Principal
951 field_principal: Principal
949 notice_failed_to_save_members: "Erreur lors de la sauvegarde des membres: %{errors}."
952 notice_failed_to_save_members: "Erreur lors de la sauvegarde des membres: %{errors}."
950 text_zoom_out: Zoom arrière
953 text_zoom_out: Zoom arrière
951 text_zoom_in: Zoom avant
954 text_zoom_in: Zoom avant
952 notice_unable_delete_time_entry: Impossible de supprimer le temps passΓ©.
955 notice_unable_delete_time_entry: Impossible de supprimer le temps passΓ©.
953 label_overall_spent_time: Temps passΓ© global
956 label_overall_spent_time: Temps passΓ© global
954 field_time_entries: Log time
957 field_time_entries: Log time
955 project_module_gantt: Gantt
958 project_module_gantt: Gantt
956 project_module_calendar: Calendrier
959 project_module_calendar: Calendrier
957 button_edit_associated_wikipage: "Modifier la page wiki associΓ©e: %{page_title}"
960 button_edit_associated_wikipage: "Modifier la page wiki associΓ©e: %{page_title}"
958 text_are_you_sure_with_children: Supprimer la demande et toutes ses sous-demandes ?
961 text_are_you_sure_with_children: Supprimer la demande et toutes ses sous-demandes ?
959 field_text: Champ texte
962 field_text: Champ texte
960 label_user_mail_option_only_owner: Seulement pour ce que j'ai créé
963 label_user_mail_option_only_owner: Seulement pour ce que j'ai créé
961 setting_default_notification_option: Option de notification par dΓ©faut
964 setting_default_notification_option: Option de notification par dΓ©faut
962 label_user_mail_option_only_my_events: Seulement pour ce que je surveille
965 label_user_mail_option_only_my_events: Seulement pour ce que je surveille
963 label_user_mail_option_only_assigned: Seulement pour ce qui m'est assignΓ©
966 label_user_mail_option_only_assigned: Seulement pour ce qui m'est assignΓ©
964 label_user_mail_option_none: Aucune notification
967 label_user_mail_option_none: Aucune notification
965 field_member_of_group: Groupe de l'assignΓ©
968 field_member_of_group: Groupe de l'assignΓ©
966 field_assigned_to_role: RΓ΄le de l'assignΓ©
969 field_assigned_to_role: RΓ΄le de l'assignΓ©
967 setting_emails_header: En-tΓͺte des emails
970 setting_emails_header: En-tΓͺte des emails
968 label_bulk_edit_selected_time_entries: Bulk edit selected time entries
971 label_bulk_edit_selected_time_entries: Bulk edit selected time entries
969 text_time_entries_destroy_confirmation: Are you sure you want to delete the selected time entr(y/ies)?
972 text_time_entries_destroy_confirmation: Are you sure you want to delete the selected time entr(y/ies)?
@@ -1,188 +1,193
1 ---
1 ---
2 roles_001:
2 roles_001:
3 name: Manager
3 name: Manager
4 id: 1
4 id: 1
5 builtin: 0
5 builtin: 0
6 issues_visibility: default
6 permissions: |
7 permissions: |
7 ---
8 ---
8 - :add_project
9 - :add_project
9 - :edit_project
10 - :edit_project
10 - :select_project_modules
11 - :select_project_modules
11 - :manage_members
12 - :manage_members
12 - :manage_versions
13 - :manage_versions
13 - :manage_categories
14 - :manage_categories
14 - :view_issues
15 - :view_issues
15 - :add_issues
16 - :add_issues
16 - :edit_issues
17 - :edit_issues
17 - :manage_issue_relations
18 - :manage_issue_relations
18 - :manage_subtasks
19 - :manage_subtasks
19 - :add_issue_notes
20 - :add_issue_notes
20 - :move_issues
21 - :move_issues
21 - :delete_issues
22 - :delete_issues
22 - :view_issue_watchers
23 - :view_issue_watchers
23 - :add_issue_watchers
24 - :add_issue_watchers
24 - :delete_issue_watchers
25 - :delete_issue_watchers
25 - :manage_public_queries
26 - :manage_public_queries
26 - :save_queries
27 - :save_queries
27 - :view_gantt
28 - :view_gantt
28 - :view_calendar
29 - :view_calendar
29 - :log_time
30 - :log_time
30 - :view_time_entries
31 - :view_time_entries
31 - :edit_time_entries
32 - :edit_time_entries
32 - :delete_time_entries
33 - :delete_time_entries
33 - :manage_news
34 - :manage_news
34 - :comment_news
35 - :comment_news
35 - :view_documents
36 - :view_documents
36 - :manage_documents
37 - :manage_documents
37 - :view_wiki_pages
38 - :view_wiki_pages
38 - :export_wiki_pages
39 - :export_wiki_pages
39 - :view_wiki_edits
40 - :view_wiki_edits
40 - :edit_wiki_pages
41 - :edit_wiki_pages
41 - :delete_wiki_pages_attachments
42 - :delete_wiki_pages_attachments
42 - :protect_wiki_pages
43 - :protect_wiki_pages
43 - :delete_wiki_pages
44 - :delete_wiki_pages
44 - :rename_wiki_pages
45 - :rename_wiki_pages
45 - :add_messages
46 - :add_messages
46 - :edit_messages
47 - :edit_messages
47 - :delete_messages
48 - :delete_messages
48 - :manage_boards
49 - :manage_boards
49 - :view_files
50 - :view_files
50 - :manage_files
51 - :manage_files
51 - :browse_repository
52 - :browse_repository
52 - :manage_repository
53 - :manage_repository
53 - :view_changesets
54 - :view_changesets
54 - :manage_project_activities
55 - :manage_project_activities
55
56
56 position: 1
57 position: 1
57 roles_002:
58 roles_002:
58 name: Developer
59 name: Developer
59 id: 2
60 id: 2
60 builtin: 0
61 builtin: 0
62 issues_visibility: default
61 permissions: |
63 permissions: |
62 ---
64 ---
63 - :edit_project
65 - :edit_project
64 - :manage_members
66 - :manage_members
65 - :manage_versions
67 - :manage_versions
66 - :manage_categories
68 - :manage_categories
67 - :view_issues
69 - :view_issues
68 - :add_issues
70 - :add_issues
69 - :edit_issues
71 - :edit_issues
70 - :manage_issue_relations
72 - :manage_issue_relations
71 - :manage_subtasks
73 - :manage_subtasks
72 - :add_issue_notes
74 - :add_issue_notes
73 - :move_issues
75 - :move_issues
74 - :delete_issues
76 - :delete_issues
75 - :view_issue_watchers
77 - :view_issue_watchers
76 - :save_queries
78 - :save_queries
77 - :view_gantt
79 - :view_gantt
78 - :view_calendar
80 - :view_calendar
79 - :log_time
81 - :log_time
80 - :view_time_entries
82 - :view_time_entries
81 - :edit_own_time_entries
83 - :edit_own_time_entries
82 - :manage_news
84 - :manage_news
83 - :comment_news
85 - :comment_news
84 - :view_documents
86 - :view_documents
85 - :manage_documents
87 - :manage_documents
86 - :view_wiki_pages
88 - :view_wiki_pages
87 - :view_wiki_edits
89 - :view_wiki_edits
88 - :edit_wiki_pages
90 - :edit_wiki_pages
89 - :protect_wiki_pages
91 - :protect_wiki_pages
90 - :delete_wiki_pages
92 - :delete_wiki_pages
91 - :add_messages
93 - :add_messages
92 - :edit_own_messages
94 - :edit_own_messages
93 - :delete_own_messages
95 - :delete_own_messages
94 - :manage_boards
96 - :manage_boards
95 - :view_files
97 - :view_files
96 - :manage_files
98 - :manage_files
97 - :browse_repository
99 - :browse_repository
98 - :view_changesets
100 - :view_changesets
99
101
100 position: 2
102 position: 2
101 roles_003:
103 roles_003:
102 name: Reporter
104 name: Reporter
103 id: 3
105 id: 3
104 builtin: 0
106 builtin: 0
107 issues_visibility: default
105 permissions: |
108 permissions: |
106 ---
109 ---
107 - :edit_project
110 - :edit_project
108 - :manage_members
111 - :manage_members
109 - :manage_versions
112 - :manage_versions
110 - :manage_categories
113 - :manage_categories
111 - :view_issues
114 - :view_issues
112 - :add_issues
115 - :add_issues
113 - :edit_issues
116 - :edit_issues
114 - :manage_issue_relations
117 - :manage_issue_relations
115 - :add_issue_notes
118 - :add_issue_notes
116 - :move_issues
119 - :move_issues
117 - :view_issue_watchers
120 - :view_issue_watchers
118 - :save_queries
121 - :save_queries
119 - :view_gantt
122 - :view_gantt
120 - :view_calendar
123 - :view_calendar
121 - :log_time
124 - :log_time
122 - :view_time_entries
125 - :view_time_entries
123 - :manage_news
126 - :manage_news
124 - :comment_news
127 - :comment_news
125 - :view_documents
128 - :view_documents
126 - :manage_documents
129 - :manage_documents
127 - :view_wiki_pages
130 - :view_wiki_pages
128 - :view_wiki_edits
131 - :view_wiki_edits
129 - :edit_wiki_pages
132 - :edit_wiki_pages
130 - :delete_wiki_pages
133 - :delete_wiki_pages
131 - :add_messages
134 - :add_messages
132 - :manage_boards
135 - :manage_boards
133 - :view_files
136 - :view_files
134 - :manage_files
137 - :manage_files
135 - :browse_repository
138 - :browse_repository
136 - :view_changesets
139 - :view_changesets
137
140
138 position: 3
141 position: 3
139 roles_004:
142 roles_004:
140 name: Non member
143 name: Non member
141 id: 4
144 id: 4
142 builtin: 1
145 builtin: 1
146 issues_visibility: default
143 permissions: |
147 permissions: |
144 ---
148 ---
145 - :view_issues
149 - :view_issues
146 - :add_issues
150 - :add_issues
147 - :edit_issues
151 - :edit_issues
148 - :manage_issue_relations
152 - :manage_issue_relations
149 - :add_issue_notes
153 - :add_issue_notes
150 - :move_issues
154 - :move_issues
151 - :save_queries
155 - :save_queries
152 - :view_gantt
156 - :view_gantt
153 - :view_calendar
157 - :view_calendar
154 - :log_time
158 - :log_time
155 - :view_time_entries
159 - :view_time_entries
156 - :comment_news
160 - :comment_news
157 - :view_documents
161 - :view_documents
158 - :manage_documents
162 - :manage_documents
159 - :view_wiki_pages
163 - :view_wiki_pages
160 - :view_wiki_edits
164 - :view_wiki_edits
161 - :edit_wiki_pages
165 - :edit_wiki_pages
162 - :add_messages
166 - :add_messages
163 - :view_files
167 - :view_files
164 - :manage_files
168 - :manage_files
165 - :browse_repository
169 - :browse_repository
166 - :view_changesets
170 - :view_changesets
167
171
168 position: 4
172 position: 4
169 roles_005:
173 roles_005:
170 name: Anonymous
174 name: Anonymous
171 id: 5
175 id: 5
172 builtin: 2
176 builtin: 2
177 issues_visibility: default
173 permissions: |
178 permissions: |
174 ---
179 ---
175 - :view_issues
180 - :view_issues
176 - :add_issue_notes
181 - :add_issue_notes
177 - :view_gantt
182 - :view_gantt
178 - :view_calendar
183 - :view_calendar
179 - :view_time_entries
184 - :view_time_entries
180 - :view_documents
185 - :view_documents
181 - :view_wiki_pages
186 - :view_wiki_pages
182 - :view_wiki_edits
187 - :view_wiki_edits
183 - :view_files
188 - :view_files
184 - :browse_repository
189 - :browse_repository
185 - :view_changesets
190 - :view_changesets
186
191
187 position: 5
192 position: 5
188
193
@@ -1,921 +1,963
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
2 # Copyright (C) 2006-2011 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
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
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.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.expand_path('../../test_helper', __FILE__)
18 require File.expand_path('../../test_helper', __FILE__)
19
19
20 class IssueTest < ActiveSupport::TestCase
20 class IssueTest < ActiveSupport::TestCase
21 fixtures :projects, :users, :members, :member_roles, :roles,
21 fixtures :projects, :users, :members, :member_roles, :roles,
22 :trackers, :projects_trackers,
22 :trackers, :projects_trackers,
23 :enabled_modules,
23 :enabled_modules,
24 :versions,
24 :versions,
25 :issue_statuses, :issue_categories, :issue_relations, :workflows,
25 :issue_statuses, :issue_categories, :issue_relations, :workflows,
26 :enumerations,
26 :enumerations,
27 :issues,
27 :issues,
28 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
28 :custom_fields, :custom_fields_projects, :custom_fields_trackers, :custom_values,
29 :time_entries
29 :time_entries
30
30
31 def test_create
31 def test_create
32 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :description => 'IssueTest#test_create', :estimated_hours => '1:30')
32 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create', :description => 'IssueTest#test_create', :estimated_hours => '1:30')
33 assert issue.save
33 assert issue.save
34 issue.reload
34 issue.reload
35 assert_equal 1.5, issue.estimated_hours
35 assert_equal 1.5, issue.estimated_hours
36 end
36 end
37
37
38 def test_create_minimal
38 def test_create_minimal
39 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create')
39 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 3, :status_id => 1, :priority => IssuePriority.all.first, :subject => 'test_create')
40 assert issue.save
40 assert issue.save
41 assert issue.description.nil?
41 assert issue.description.nil?
42 end
42 end
43
43
44 def test_create_with_required_custom_field
44 def test_create_with_required_custom_field
45 field = IssueCustomField.find_by_name('Database')
45 field = IssueCustomField.find_by_name('Database')
46 field.update_attribute(:is_required, true)
46 field.update_attribute(:is_required, true)
47
47
48 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'test_create', :description => 'IssueTest#test_create_with_required_custom_field')
48 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => 'test_create', :description => 'IssueTest#test_create_with_required_custom_field')
49 assert issue.available_custom_fields.include?(field)
49 assert issue.available_custom_fields.include?(field)
50 # No value for the custom field
50 # No value for the custom field
51 assert !issue.save
51 assert !issue.save
52 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
52 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
53 # Blank value
53 # Blank value
54 issue.custom_field_values = { field.id => '' }
54 issue.custom_field_values = { field.id => '' }
55 assert !issue.save
55 assert !issue.save
56 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
56 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
57 # Invalid value
57 # Invalid value
58 issue.custom_field_values = { field.id => 'SQLServer' }
58 issue.custom_field_values = { field.id => 'SQLServer' }
59 assert !issue.save
59 assert !issue.save
60 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
60 assert_equal I18n.translate('activerecord.errors.messages.invalid'), issue.errors.on(:custom_values)
61 # Valid value
61 # Valid value
62 issue.custom_field_values = { field.id => 'PostgreSQL' }
62 issue.custom_field_values = { field.id => 'PostgreSQL' }
63 assert issue.save
63 assert issue.save
64 issue.reload
64 issue.reload
65 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
65 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
66 end
66 end
67
67
68 def assert_visibility_match(user, issues)
69 assert_equal issues.collect(&:id).sort, Issue.all.select {|issue| issue.visible?(user)}.collect(&:id).sort
70 end
71
68 def test_visible_scope_for_anonymous
72 def test_visible_scope_for_anonymous
69 # Anonymous user should see issues of public projects only
73 # Anonymous user should see issues of public projects only
70 issues = Issue.visible(User.anonymous).all
74 issues = Issue.visible(User.anonymous).all
71 assert issues.any?
75 assert issues.any?
72 assert_nil issues.detect {|issue| !issue.project.is_public?}
76 assert_nil issues.detect {|issue| !issue.project.is_public?}
77 assert_visibility_match User.anonymous, issues
78 end
79
80 def test_visible_scope_for_anonymous_with_own_issues_visibility
81 Role.anonymous.update_attribute :issues_visibility, 'own'
82 Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => User.anonymous.id, :subject => 'Issue by anonymous')
83
84 issues = Issue.visible(User.anonymous).all
85 assert issues.any?
86 assert_nil issues.detect {|issue| issue.author != User.anonymous}
87 assert_visibility_match User.anonymous, issues
88 end
89
90 def test_visible_scope_for_anonymous_without_view_issues_permissions
73 # Anonymous user should not see issues without permission
91 # Anonymous user should not see issues without permission
74 Role.anonymous.remove_permission!(:view_issues)
92 Role.anonymous.remove_permission!(:view_issues)
75 issues = Issue.visible(User.anonymous).all
93 issues = Issue.visible(User.anonymous).all
76 assert issues.empty?
94 assert issues.empty?
95 assert_visibility_match User.anonymous, issues
77 end
96 end
78
97
79 def test_visible_scope_for_user
98 def test_visible_scope_for_non_member
80 user = User.find(9)
99 user = User.find(9)
81 assert user.projects.empty?
100 assert user.projects.empty?
82 # Non member user should see issues of public projects only
101 # Non member user should see issues of public projects only
83 issues = Issue.visible(user).all
102 issues = Issue.visible(user).all
84 assert issues.any?
103 assert issues.any?
85 assert_nil issues.detect {|issue| !issue.project.is_public?}
104 assert_nil issues.detect {|issue| !issue.project.is_public?}
105 assert_visibility_match user, issues
106 end
107
108 def test_visible_scope_for_non_member_with_own_issues_visibility
109 Role.non_member.update_attribute :issues_visibility, 'own'
110 Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 9, :subject => 'Issue by non member')
111 user = User.find(9)
112
113 issues = Issue.visible(user).all
114 assert issues.any?
115 assert_nil issues.detect {|issue| issue.author != user}
116 assert_visibility_match user, issues
117 end
118
119 def test_visible_scope_for_non_member_without_view_issues_permissions
86 # Non member user should not see issues without permission
120 # Non member user should not see issues without permission
87 Role.non_member.remove_permission!(:view_issues)
121 Role.non_member.remove_permission!(:view_issues)
88 user.reload
122 user = User.find(9)
123 assert user.projects.empty?
89 issues = Issue.visible(user).all
124 issues = Issue.visible(user).all
90 assert issues.empty?
125 assert issues.empty?
126 assert_visibility_match user, issues
127 end
128
129 def test_visible_scope_for_member
130 user = User.find(9)
91 # User should see issues of projects for which he has view_issues permissions only
131 # User should see issues of projects for which he has view_issues permissions only
132 Role.non_member.remove_permission!(:view_issues)
92 Member.create!(:principal => user, :project_id => 2, :role_ids => [1])
133 Member.create!(:principal => user, :project_id => 2, :role_ids => [1])
93 user.reload
94 issues = Issue.visible(user).all
134 issues = Issue.visible(user).all
95 assert issues.any?
135 assert issues.any?
96 assert_nil issues.detect {|issue| issue.project_id != 2}
136 assert_nil issues.detect {|issue| issue.project_id != 2}
137 assert_visibility_match user, issues
97 end
138 end
98
139
99 def test_visible_scope_for_admin
140 def test_visible_scope_for_admin
100 user = User.find(1)
141 user = User.find(1)
101 user.members.each(&:destroy)
142 user.members.each(&:destroy)
102 assert user.projects.empty?
143 assert user.projects.empty?
103 issues = Issue.visible(user).all
144 issues = Issue.visible(user).all
104 assert issues.any?
145 assert issues.any?
105 # Admin should see issues on private projects that he does not belong to
146 # Admin should see issues on private projects that he does not belong to
106 assert issues.detect {|issue| !issue.project.is_public?}
147 assert issues.detect {|issue| !issue.project.is_public?}
148 assert_visibility_match user, issues
107 end
149 end
108
150
109 def test_visible_scope_with_project
151 def test_visible_scope_with_project
110 project = Project.find(1)
152 project = Project.find(1)
111 issues = Issue.visible(User.find(2), :project => project).all
153 issues = Issue.visible(User.find(2), :project => project).all
112 projects = issues.collect(&:project).uniq
154 projects = issues.collect(&:project).uniq
113 assert_equal 1, projects.size
155 assert_equal 1, projects.size
114 assert_equal project, projects.first
156 assert_equal project, projects.first
115 end
157 end
116
158
117 def test_visible_scope_with_project_and_subprojects
159 def test_visible_scope_with_project_and_subprojects
118 project = Project.find(1)
160 project = Project.find(1)
119 issues = Issue.visible(User.find(2), :project => project, :with_subprojects => true).all
161 issues = Issue.visible(User.find(2), :project => project, :with_subprojects => true).all
120 projects = issues.collect(&:project).uniq
162 projects = issues.collect(&:project).uniq
121 assert projects.size > 1
163 assert projects.size > 1
122 assert_equal [], projects.select {|p| !p.is_or_is_descendant_of?(project)}
164 assert_equal [], projects.select {|p| !p.is_or_is_descendant_of?(project)}
123 end
165 end
124
166
125 def test_errors_full_messages_should_include_custom_fields_errors
167 def test_errors_full_messages_should_include_custom_fields_errors
126 field = IssueCustomField.find_by_name('Database')
168 field = IssueCustomField.find_by_name('Database')
127
169
128 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')
170 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')
129 assert issue.available_custom_fields.include?(field)
171 assert issue.available_custom_fields.include?(field)
130 # Invalid value
172 # Invalid value
131 issue.custom_field_values = { field.id => 'SQLServer' }
173 issue.custom_field_values = { field.id => 'SQLServer' }
132
174
133 assert !issue.valid?
175 assert !issue.valid?
134 assert_equal 1, issue.errors.full_messages.size
176 assert_equal 1, issue.errors.full_messages.size
135 assert_equal "Database #{I18n.translate('activerecord.errors.messages.inclusion')}", issue.errors.full_messages.first
177 assert_equal "Database #{I18n.translate('activerecord.errors.messages.inclusion')}", issue.errors.full_messages.first
136 end
178 end
137
179
138 def test_update_issue_with_required_custom_field
180 def test_update_issue_with_required_custom_field
139 field = IssueCustomField.find_by_name('Database')
181 field = IssueCustomField.find_by_name('Database')
140 field.update_attribute(:is_required, true)
182 field.update_attribute(:is_required, true)
141
183
142 issue = Issue.find(1)
184 issue = Issue.find(1)
143 assert_nil issue.custom_value_for(field)
185 assert_nil issue.custom_value_for(field)
144 assert issue.available_custom_fields.include?(field)
186 assert issue.available_custom_fields.include?(field)
145 # No change to custom values, issue can be saved
187 # No change to custom values, issue can be saved
146 assert issue.save
188 assert issue.save
147 # Blank value
189 # Blank value
148 issue.custom_field_values = { field.id => '' }
190 issue.custom_field_values = { field.id => '' }
149 assert !issue.save
191 assert !issue.save
150 # Valid value
192 # Valid value
151 issue.custom_field_values = { field.id => 'PostgreSQL' }
193 issue.custom_field_values = { field.id => 'PostgreSQL' }
152 assert issue.save
194 assert issue.save
153 issue.reload
195 issue.reload
154 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
196 assert_equal 'PostgreSQL', issue.custom_value_for(field).value
155 end
197 end
156
198
157 def test_should_not_update_attributes_if_custom_fields_validation_fails
199 def test_should_not_update_attributes_if_custom_fields_validation_fails
158 issue = Issue.find(1)
200 issue = Issue.find(1)
159 field = IssueCustomField.find_by_name('Database')
201 field = IssueCustomField.find_by_name('Database')
160 assert issue.available_custom_fields.include?(field)
202 assert issue.available_custom_fields.include?(field)
161
203
162 issue.custom_field_values = { field.id => 'Invalid' }
204 issue.custom_field_values = { field.id => 'Invalid' }
163 issue.subject = 'Should be not be saved'
205 issue.subject = 'Should be not be saved'
164 assert !issue.save
206 assert !issue.save
165
207
166 issue.reload
208 issue.reload
167 assert_equal "Can't print recipes", issue.subject
209 assert_equal "Can't print recipes", issue.subject
168 end
210 end
169
211
170 def test_should_not_recreate_custom_values_objects_on_update
212 def test_should_not_recreate_custom_values_objects_on_update
171 field = IssueCustomField.find_by_name('Database')
213 field = IssueCustomField.find_by_name('Database')
172
214
173 issue = Issue.find(1)
215 issue = Issue.find(1)
174 issue.custom_field_values = { field.id => 'PostgreSQL' }
216 issue.custom_field_values = { field.id => 'PostgreSQL' }
175 assert issue.save
217 assert issue.save
176 custom_value = issue.custom_value_for(field)
218 custom_value = issue.custom_value_for(field)
177 issue.reload
219 issue.reload
178 issue.custom_field_values = { field.id => 'MySQL' }
220 issue.custom_field_values = { field.id => 'MySQL' }
179 assert issue.save
221 assert issue.save
180 issue.reload
222 issue.reload
181 assert_equal custom_value.id, issue.custom_value_for(field).id
223 assert_equal custom_value.id, issue.custom_value_for(field).id
182 end
224 end
183
225
184 def test_assigning_tracker_id_should_reload_custom_fields_values
226 def test_assigning_tracker_id_should_reload_custom_fields_values
185 issue = Issue.new(:project => Project.find(1))
227 issue = Issue.new(:project => Project.find(1))
186 assert issue.custom_field_values.empty?
228 assert issue.custom_field_values.empty?
187 issue.tracker_id = 1
229 issue.tracker_id = 1
188 assert issue.custom_field_values.any?
230 assert issue.custom_field_values.any?
189 end
231 end
190
232
191 def test_assigning_attributes_should_assign_tracker_id_first
233 def test_assigning_attributes_should_assign_tracker_id_first
192 attributes = ActiveSupport::OrderedHash.new
234 attributes = ActiveSupport::OrderedHash.new
193 attributes['custom_field_values'] = { '1' => 'MySQL' }
235 attributes['custom_field_values'] = { '1' => 'MySQL' }
194 attributes['tracker_id'] = '1'
236 attributes['tracker_id'] = '1'
195 issue = Issue.new(:project => Project.find(1))
237 issue = Issue.new(:project => Project.find(1))
196 issue.attributes = attributes
238 issue.attributes = attributes
197 assert_not_nil issue.custom_value_for(1)
239 assert_not_nil issue.custom_value_for(1)
198 assert_equal 'MySQL', issue.custom_value_for(1).value
240 assert_equal 'MySQL', issue.custom_value_for(1).value
199 end
241 end
200
242
201 def test_should_update_issue_with_disabled_tracker
243 def test_should_update_issue_with_disabled_tracker
202 p = Project.find(1)
244 p = Project.find(1)
203 issue = Issue.find(1)
245 issue = Issue.find(1)
204
246
205 p.trackers.delete(issue.tracker)
247 p.trackers.delete(issue.tracker)
206 assert !p.trackers.include?(issue.tracker)
248 assert !p.trackers.include?(issue.tracker)
207
249
208 issue.reload
250 issue.reload
209 issue.subject = 'New subject'
251 issue.subject = 'New subject'
210 assert issue.save
252 assert issue.save
211 end
253 end
212
254
213 def test_should_not_set_a_disabled_tracker
255 def test_should_not_set_a_disabled_tracker
214 p = Project.find(1)
256 p = Project.find(1)
215 p.trackers.delete(Tracker.find(2))
257 p.trackers.delete(Tracker.find(2))
216
258
217 issue = Issue.find(1)
259 issue = Issue.find(1)
218 issue.tracker_id = 2
260 issue.tracker_id = 2
219 issue.subject = 'New subject'
261 issue.subject = 'New subject'
220 assert !issue.save
262 assert !issue.save
221 assert_not_nil issue.errors.on(:tracker_id)
263 assert_not_nil issue.errors.on(:tracker_id)
222 end
264 end
223
265
224 def test_category_based_assignment
266 def test_category_based_assignment
225 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)
267 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)
226 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
268 assert_equal IssueCategory.find(1).assigned_to, issue.assigned_to
227 end
269 end
228
270
229
271
230
272
231 def test_new_statuses_allowed_to
273 def test_new_statuses_allowed_to
232 Workflow.delete_all
274 Workflow.delete_all
233
275
234 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 2, :author => false, :assignee => false)
276 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 2, :author => false, :assignee => false)
235 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 3, :author => true, :assignee => false)
277 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 3, :author => true, :assignee => false)
236 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 4, :author => false, :assignee => true)
278 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 4, :author => false, :assignee => true)
237 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 5, :author => true, :assignee => true)
279 Workflow.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 5, :author => true, :assignee => true)
238 status = IssueStatus.find(1)
280 status = IssueStatus.find(1)
239 role = Role.find(1)
281 role = Role.find(1)
240 tracker = Tracker.find(1)
282 tracker = Tracker.find(1)
241 user = User.find(2)
283 user = User.find(2)
242
284
243 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1)
285 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1)
244 assert_equal [1, 2], issue.new_statuses_allowed_to(user).map(&:id)
286 assert_equal [1, 2], issue.new_statuses_allowed_to(user).map(&:id)
245
287
246 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :author => user)
288 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :author => user)
247 assert_equal [1, 2, 3], issue.new_statuses_allowed_to(user).map(&:id)
289 assert_equal [1, 2, 3], issue.new_statuses_allowed_to(user).map(&:id)
248
290
249 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :assigned_to => user)
291 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :assigned_to => user)
250 assert_equal [1, 2, 4], issue.new_statuses_allowed_to(user).map(&:id)
292 assert_equal [1, 2, 4], issue.new_statuses_allowed_to(user).map(&:id)
251
293
252 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :author => user, :assigned_to => user)
294 issue = Issue.generate!(:tracker => tracker, :status => status, :project_id => 1, :author => user, :assigned_to => user)
253 assert_equal [1, 2, 3, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
295 assert_equal [1, 2, 3, 4, 5], issue.new_statuses_allowed_to(user).map(&:id)
254 end
296 end
255
297
256 def test_copy
298 def test_copy
257 issue = Issue.new.copy_from(1)
299 issue = Issue.new.copy_from(1)
258 assert issue.save
300 assert issue.save
259 issue.reload
301 issue.reload
260 orig = Issue.find(1)
302 orig = Issue.find(1)
261 assert_equal orig.subject, issue.subject
303 assert_equal orig.subject, issue.subject
262 assert_equal orig.tracker, issue.tracker
304 assert_equal orig.tracker, issue.tracker
263 assert_equal "125", issue.custom_value_for(2).value
305 assert_equal "125", issue.custom_value_for(2).value
264 end
306 end
265
307
266 def test_copy_should_copy_status
308 def test_copy_should_copy_status
267 orig = Issue.find(8)
309 orig = Issue.find(8)
268 assert orig.status != IssueStatus.default
310 assert orig.status != IssueStatus.default
269
311
270 issue = Issue.new.copy_from(orig)
312 issue = Issue.new.copy_from(orig)
271 assert issue.save
313 assert issue.save
272 issue.reload
314 issue.reload
273 assert_equal orig.status, issue.status
315 assert_equal orig.status, issue.status
274 end
316 end
275
317
276 def test_should_close_duplicates
318 def test_should_close_duplicates
277 # Create 3 issues
319 # Create 3 issues
278 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')
320 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')
279 assert issue1.save
321 assert issue1.save
280 issue2 = issue1.clone
322 issue2 = issue1.clone
281 assert issue2.save
323 assert issue2.save
282 issue3 = issue1.clone
324 issue3 = issue1.clone
283 assert issue3.save
325 assert issue3.save
284
326
285 # 2 is a dupe of 1
327 # 2 is a dupe of 1
286 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
328 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
287 # And 3 is a dupe of 2
329 # And 3 is a dupe of 2
288 IssueRelation.create(:issue_from => issue3, :issue_to => issue2, :relation_type => IssueRelation::TYPE_DUPLICATES)
330 IssueRelation.create(:issue_from => issue3, :issue_to => issue2, :relation_type => IssueRelation::TYPE_DUPLICATES)
289 # And 3 is a dupe of 1 (circular duplicates)
331 # And 3 is a dupe of 1 (circular duplicates)
290 IssueRelation.create(:issue_from => issue3, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
332 IssueRelation.create(:issue_from => issue3, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
291
333
292 assert issue1.reload.duplicates.include?(issue2)
334 assert issue1.reload.duplicates.include?(issue2)
293
335
294 # Closing issue 1
336 # Closing issue 1
295 issue1.init_journal(User.find(:first), "Closing issue1")
337 issue1.init_journal(User.find(:first), "Closing issue1")
296 issue1.status = IssueStatus.find :first, :conditions => {:is_closed => true}
338 issue1.status = IssueStatus.find :first, :conditions => {:is_closed => true}
297 assert issue1.save
339 assert issue1.save
298 # 2 and 3 should be also closed
340 # 2 and 3 should be also closed
299 assert issue2.reload.closed?
341 assert issue2.reload.closed?
300 assert issue3.reload.closed?
342 assert issue3.reload.closed?
301 end
343 end
302
344
303 def test_should_not_close_duplicated_issue
345 def test_should_not_close_duplicated_issue
304 # Create 3 issues
346 # Create 3 issues
305 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')
347 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')
306 assert issue1.save
348 assert issue1.save
307 issue2 = issue1.clone
349 issue2 = issue1.clone
308 assert issue2.save
350 assert issue2.save
309
351
310 # 2 is a dupe of 1
352 # 2 is a dupe of 1
311 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
353 IssueRelation.create(:issue_from => issue2, :issue_to => issue1, :relation_type => IssueRelation::TYPE_DUPLICATES)
312 # 2 is a dup of 1 but 1 is not a duplicate of 2
354 # 2 is a dup of 1 but 1 is not a duplicate of 2
313 assert !issue2.reload.duplicates.include?(issue1)
355 assert !issue2.reload.duplicates.include?(issue1)
314
356
315 # Closing issue 2
357 # Closing issue 2
316 issue2.init_journal(User.find(:first), "Closing issue2")
358 issue2.init_journal(User.find(:first), "Closing issue2")
317 issue2.status = IssueStatus.find :first, :conditions => {:is_closed => true}
359 issue2.status = IssueStatus.find :first, :conditions => {:is_closed => true}
318 assert issue2.save
360 assert issue2.save
319 # 1 should not be also closed
361 # 1 should not be also closed
320 assert !issue1.reload.closed?
362 assert !issue1.reload.closed?
321 end
363 end
322
364
323 def test_assignable_versions
365 def test_assignable_versions
324 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
366 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
325 assert_equal ['open'], issue.assignable_versions.collect(&:status).uniq
367 assert_equal ['open'], issue.assignable_versions.collect(&:status).uniq
326 end
368 end
327
369
328 def test_should_not_be_able_to_assign_a_new_issue_to_a_closed_version
370 def test_should_not_be_able_to_assign_a_new_issue_to_a_closed_version
329 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
371 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 1, :subject => 'New issue')
330 assert !issue.save
372 assert !issue.save
331 assert_not_nil issue.errors.on(:fixed_version_id)
373 assert_not_nil issue.errors.on(:fixed_version_id)
332 end
374 end
333
375
334 def test_should_not_be_able_to_assign_a_new_issue_to_a_locked_version
376 def test_should_not_be_able_to_assign_a_new_issue_to_a_locked_version
335 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 2, :subject => 'New issue')
377 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 2, :subject => 'New issue')
336 assert !issue.save
378 assert !issue.save
337 assert_not_nil issue.errors.on(:fixed_version_id)
379 assert_not_nil issue.errors.on(:fixed_version_id)
338 end
380 end
339
381
340 def test_should_be_able_to_assign_a_new_issue_to_an_open_version
382 def test_should_be_able_to_assign_a_new_issue_to_an_open_version
341 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 3, :subject => 'New issue')
383 issue = Issue.new(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :fixed_version_id => 3, :subject => 'New issue')
342 assert issue.save
384 assert issue.save
343 end
385 end
344
386
345 def test_should_be_able_to_update_an_issue_assigned_to_a_closed_version
387 def test_should_be_able_to_update_an_issue_assigned_to_a_closed_version
346 issue = Issue.find(11)
388 issue = Issue.find(11)
347 assert_equal 'closed', issue.fixed_version.status
389 assert_equal 'closed', issue.fixed_version.status
348 issue.subject = 'Subject changed'
390 issue.subject = 'Subject changed'
349 assert issue.save
391 assert issue.save
350 end
392 end
351
393
352 def test_should_not_be_able_to_reopen_an_issue_assigned_to_a_closed_version
394 def test_should_not_be_able_to_reopen_an_issue_assigned_to_a_closed_version
353 issue = Issue.find(11)
395 issue = Issue.find(11)
354 issue.status_id = 1
396 issue.status_id = 1
355 assert !issue.save
397 assert !issue.save
356 assert_not_nil issue.errors.on_base
398 assert_not_nil issue.errors.on_base
357 end
399 end
358
400
359 def test_should_be_able_to_reopen_and_reassign_an_issue_assigned_to_a_closed_version
401 def test_should_be_able_to_reopen_and_reassign_an_issue_assigned_to_a_closed_version
360 issue = Issue.find(11)
402 issue = Issue.find(11)
361 issue.status_id = 1
403 issue.status_id = 1
362 issue.fixed_version_id = 3
404 issue.fixed_version_id = 3
363 assert issue.save
405 assert issue.save
364 end
406 end
365
407
366 def test_should_be_able_to_reopen_an_issue_assigned_to_a_locked_version
408 def test_should_be_able_to_reopen_an_issue_assigned_to_a_locked_version
367 issue = Issue.find(12)
409 issue = Issue.find(12)
368 assert_equal 'locked', issue.fixed_version.status
410 assert_equal 'locked', issue.fixed_version.status
369 issue.status_id = 1
411 issue.status_id = 1
370 assert issue.save
412 assert issue.save
371 end
413 end
372
414
373 def test_move_to_another_project_with_same_category
415 def test_move_to_another_project_with_same_category
374 issue = Issue.find(1)
416 issue = Issue.find(1)
375 assert issue.move_to_project(Project.find(2))
417 assert issue.move_to_project(Project.find(2))
376 issue.reload
418 issue.reload
377 assert_equal 2, issue.project_id
419 assert_equal 2, issue.project_id
378 # Category changes
420 # Category changes
379 assert_equal 4, issue.category_id
421 assert_equal 4, issue.category_id
380 # Make sure time entries were move to the target project
422 # Make sure time entries were move to the target project
381 assert_equal 2, issue.time_entries.first.project_id
423 assert_equal 2, issue.time_entries.first.project_id
382 end
424 end
383
425
384 def test_move_to_another_project_without_same_category
426 def test_move_to_another_project_without_same_category
385 issue = Issue.find(2)
427 issue = Issue.find(2)
386 assert issue.move_to_project(Project.find(2))
428 assert issue.move_to_project(Project.find(2))
387 issue.reload
429 issue.reload
388 assert_equal 2, issue.project_id
430 assert_equal 2, issue.project_id
389 # Category cleared
431 # Category cleared
390 assert_nil issue.category_id
432 assert_nil issue.category_id
391 end
433 end
392
434
393 def test_move_to_another_project_should_clear_fixed_version_when_not_shared
435 def test_move_to_another_project_should_clear_fixed_version_when_not_shared
394 issue = Issue.find(1)
436 issue = Issue.find(1)
395 issue.update_attribute(:fixed_version_id, 1)
437 issue.update_attribute(:fixed_version_id, 1)
396 assert issue.move_to_project(Project.find(2))
438 assert issue.move_to_project(Project.find(2))
397 issue.reload
439 issue.reload
398 assert_equal 2, issue.project_id
440 assert_equal 2, issue.project_id
399 # Cleared fixed_version
441 # Cleared fixed_version
400 assert_equal nil, issue.fixed_version
442 assert_equal nil, issue.fixed_version
401 end
443 end
402
444
403 def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
445 def test_move_to_another_project_should_keep_fixed_version_when_shared_with_the_target_project
404 issue = Issue.find(1)
446 issue = Issue.find(1)
405 issue.update_attribute(:fixed_version_id, 4)
447 issue.update_attribute(:fixed_version_id, 4)
406 assert issue.move_to_project(Project.find(5))
448 assert issue.move_to_project(Project.find(5))
407 issue.reload
449 issue.reload
408 assert_equal 5, issue.project_id
450 assert_equal 5, issue.project_id
409 # Keep fixed_version
451 # Keep fixed_version
410 assert_equal 4, issue.fixed_version_id
452 assert_equal 4, issue.fixed_version_id
411 end
453 end
412
454
413 def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
455 def test_move_to_another_project_should_clear_fixed_version_when_not_shared_with_the_target_project
414 issue = Issue.find(1)
456 issue = Issue.find(1)
415 issue.update_attribute(:fixed_version_id, 1)
457 issue.update_attribute(:fixed_version_id, 1)
416 assert issue.move_to_project(Project.find(5))
458 assert issue.move_to_project(Project.find(5))
417 issue.reload
459 issue.reload
418 assert_equal 5, issue.project_id
460 assert_equal 5, issue.project_id
419 # Cleared fixed_version
461 # Cleared fixed_version
420 assert_equal nil, issue.fixed_version
462 assert_equal nil, issue.fixed_version
421 end
463 end
422
464
423 def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
465 def test_move_to_another_project_should_keep_fixed_version_when_shared_systemwide
424 issue = Issue.find(1)
466 issue = Issue.find(1)
425 issue.update_attribute(:fixed_version_id, 7)
467 issue.update_attribute(:fixed_version_id, 7)
426 assert issue.move_to_project(Project.find(2))
468 assert issue.move_to_project(Project.find(2))
427 issue.reload
469 issue.reload
428 assert_equal 2, issue.project_id
470 assert_equal 2, issue.project_id
429 # Keep fixed_version
471 # Keep fixed_version
430 assert_equal 7, issue.fixed_version_id
472 assert_equal 7, issue.fixed_version_id
431 end
473 end
432
474
433 def test_move_to_another_project_with_disabled_tracker
475 def test_move_to_another_project_with_disabled_tracker
434 issue = Issue.find(1)
476 issue = Issue.find(1)
435 target = Project.find(2)
477 target = Project.find(2)
436 target.tracker_ids = [3]
478 target.tracker_ids = [3]
437 target.save
479 target.save
438 assert_equal false, issue.move_to_project(target)
480 assert_equal false, issue.move_to_project(target)
439 issue.reload
481 issue.reload
440 assert_equal 1, issue.project_id
482 assert_equal 1, issue.project_id
441 end
483 end
442
484
443 def test_copy_to_the_same_project
485 def test_copy_to_the_same_project
444 issue = Issue.find(1)
486 issue = Issue.find(1)
445 copy = nil
487 copy = nil
446 assert_difference 'Issue.count' do
488 assert_difference 'Issue.count' do
447 copy = issue.move_to_project(issue.project, nil, :copy => true)
489 copy = issue.move_to_project(issue.project, nil, :copy => true)
448 end
490 end
449 assert_kind_of Issue, copy
491 assert_kind_of Issue, copy
450 assert_equal issue.project, copy.project
492 assert_equal issue.project, copy.project
451 assert_equal "125", copy.custom_value_for(2).value
493 assert_equal "125", copy.custom_value_for(2).value
452 end
494 end
453
495
454 def test_copy_to_another_project_and_tracker
496 def test_copy_to_another_project_and_tracker
455 issue = Issue.find(1)
497 issue = Issue.find(1)
456 copy = nil
498 copy = nil
457 assert_difference 'Issue.count' do
499 assert_difference 'Issue.count' do
458 copy = issue.move_to_project(Project.find(3), Tracker.find(2), :copy => true)
500 copy = issue.move_to_project(Project.find(3), Tracker.find(2), :copy => true)
459 end
501 end
460 copy.reload
502 copy.reload
461 assert_kind_of Issue, copy
503 assert_kind_of Issue, copy
462 assert_equal Project.find(3), copy.project
504 assert_equal Project.find(3), copy.project
463 assert_equal Tracker.find(2), copy.tracker
505 assert_equal Tracker.find(2), copy.tracker
464 # Custom field #2 is not associated with target tracker
506 # Custom field #2 is not associated with target tracker
465 assert_nil copy.custom_value_for(2)
507 assert_nil copy.custom_value_for(2)
466 end
508 end
467
509
468 context "#move_to_project" do
510 context "#move_to_project" do
469 context "as a copy" do
511 context "as a copy" do
470 setup do
512 setup do
471 @issue = Issue.find(1)
513 @issue = Issue.find(1)
472 @copy = nil
514 @copy = nil
473 end
515 end
474
516
475 should "allow assigned_to changes" do
517 should "allow assigned_to changes" do
476 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:assigned_to_id => 3}})
518 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:assigned_to_id => 3}})
477 assert_equal 3, @copy.assigned_to_id
519 assert_equal 3, @copy.assigned_to_id
478 end
520 end
479
521
480 should "allow status changes" do
522 should "allow status changes" do
481 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:status_id => 2}})
523 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:status_id => 2}})
482 assert_equal 2, @copy.status_id
524 assert_equal 2, @copy.status_id
483 end
525 end
484
526
485 should "allow start date changes" do
527 should "allow start date changes" do
486 date = Date.today
528 date = Date.today
487 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:start_date => date}})
529 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:start_date => date}})
488 assert_equal date, @copy.start_date
530 assert_equal date, @copy.start_date
489 end
531 end
490
532
491 should "allow due date changes" do
533 should "allow due date changes" do
492 date = Date.today
534 date = Date.today
493 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:due_date => date}})
535 @copy = @issue.move_to_project(Project.find(3), Tracker.find(2), {:copy => true, :attributes => {:due_date => date}})
494
536
495 assert_equal date, @copy.due_date
537 assert_equal date, @copy.due_date
496 end
538 end
497 end
539 end
498 end
540 end
499
541
500 def test_recipients_should_not_include_users_that_cannot_view_the_issue
542 def test_recipients_should_not_include_users_that_cannot_view_the_issue
501 issue = Issue.find(12)
543 issue = Issue.find(12)
502 assert issue.recipients.include?(issue.author.mail)
544 assert issue.recipients.include?(issue.author.mail)
503 # move the issue to a private project
545 # move the issue to a private project
504 copy = issue.move_to_project(Project.find(5), Tracker.find(2), :copy => true)
546 copy = issue.move_to_project(Project.find(5), Tracker.find(2), :copy => true)
505 # author is not a member of project anymore
547 # author is not a member of project anymore
506 assert !copy.recipients.include?(copy.author.mail)
548 assert !copy.recipients.include?(copy.author.mail)
507 end
549 end
508
550
509 def test_watcher_recipients_should_not_include_users_that_cannot_view_the_issue
551 def test_watcher_recipients_should_not_include_users_that_cannot_view_the_issue
510 user = User.find(3)
552 user = User.find(3)
511 issue = Issue.find(9)
553 issue = Issue.find(9)
512 Watcher.create!(:user => user, :watchable => issue)
554 Watcher.create!(:user => user, :watchable => issue)
513 assert issue.watched_by?(user)
555 assert issue.watched_by?(user)
514 assert !issue.watcher_recipients.include?(user.mail)
556 assert !issue.watcher_recipients.include?(user.mail)
515 end
557 end
516
558
517 def test_issue_destroy
559 def test_issue_destroy
518 Issue.find(1).destroy
560 Issue.find(1).destroy
519 assert_nil Issue.find_by_id(1)
561 assert_nil Issue.find_by_id(1)
520 assert_nil TimeEntry.find_by_issue_id(1)
562 assert_nil TimeEntry.find_by_issue_id(1)
521 end
563 end
522
564
523 def test_blocked
565 def test_blocked
524 blocked_issue = Issue.find(9)
566 blocked_issue = Issue.find(9)
525 blocking_issue = Issue.find(10)
567 blocking_issue = Issue.find(10)
526
568
527 assert blocked_issue.blocked?
569 assert blocked_issue.blocked?
528 assert !blocking_issue.blocked?
570 assert !blocking_issue.blocked?
529 end
571 end
530
572
531 def test_blocked_issues_dont_allow_closed_statuses
573 def test_blocked_issues_dont_allow_closed_statuses
532 blocked_issue = Issue.find(9)
574 blocked_issue = Issue.find(9)
533
575
534 allowed_statuses = blocked_issue.new_statuses_allowed_to(users(:users_002))
576 allowed_statuses = blocked_issue.new_statuses_allowed_to(users(:users_002))
535 assert !allowed_statuses.empty?
577 assert !allowed_statuses.empty?
536 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
578 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
537 assert closed_statuses.empty?
579 assert closed_statuses.empty?
538 end
580 end
539
581
540 def test_unblocked_issues_allow_closed_statuses
582 def test_unblocked_issues_allow_closed_statuses
541 blocking_issue = Issue.find(10)
583 blocking_issue = Issue.find(10)
542
584
543 allowed_statuses = blocking_issue.new_statuses_allowed_to(users(:users_002))
585 allowed_statuses = blocking_issue.new_statuses_allowed_to(users(:users_002))
544 assert !allowed_statuses.empty?
586 assert !allowed_statuses.empty?
545 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
587 closed_statuses = allowed_statuses.select {|st| st.is_closed?}
546 assert !closed_statuses.empty?
588 assert !closed_statuses.empty?
547 end
589 end
548
590
549 def test_rescheduling_an_issue_should_reschedule_following_issue
591 def test_rescheduling_an_issue_should_reschedule_following_issue
550 issue1 = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => '-', :start_date => Date.today, :due_date => Date.today + 2)
592 issue1 = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => '-', :start_date => Date.today, :due_date => Date.today + 2)
551 issue2 = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => '-', :start_date => Date.today, :due_date => Date.today + 2)
593 issue2 = Issue.create!(:project_id => 1, :tracker_id => 1, :author_id => 1, :status_id => 1, :subject => '-', :start_date => Date.today, :due_date => Date.today + 2)
552 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2, :relation_type => IssueRelation::TYPE_PRECEDES)
594 IssueRelation.create!(:issue_from => issue1, :issue_to => issue2, :relation_type => IssueRelation::TYPE_PRECEDES)
553 assert_equal issue1.due_date + 1, issue2.reload.start_date
595 assert_equal issue1.due_date + 1, issue2.reload.start_date
554
596
555 issue1.due_date = Date.today + 5
597 issue1.due_date = Date.today + 5
556 issue1.save!
598 issue1.save!
557 assert_equal issue1.due_date + 1, issue2.reload.start_date
599 assert_equal issue1.due_date + 1, issue2.reload.start_date
558 end
600 end
559
601
560 def test_overdue
602 def test_overdue
561 assert Issue.new(:due_date => 1.day.ago.to_date).overdue?
603 assert Issue.new(:due_date => 1.day.ago.to_date).overdue?
562 assert !Issue.new(:due_date => Date.today).overdue?
604 assert !Issue.new(:due_date => Date.today).overdue?
563 assert !Issue.new(:due_date => 1.day.from_now.to_date).overdue?
605 assert !Issue.new(:due_date => 1.day.from_now.to_date).overdue?
564 assert !Issue.new(:due_date => nil).overdue?
606 assert !Issue.new(:due_date => nil).overdue?
565 assert !Issue.new(:due_date => 1.day.ago.to_date, :status => IssueStatus.find(:first, :conditions => {:is_closed => true})).overdue?
607 assert !Issue.new(:due_date => 1.day.ago.to_date, :status => IssueStatus.find(:first, :conditions => {:is_closed => true})).overdue?
566 end
608 end
567
609
568 context "#behind_schedule?" do
610 context "#behind_schedule?" do
569 should "be false if the issue has no start_date" do
611 should "be false if the issue has no start_date" do
570 assert !Issue.new(:start_date => nil, :due_date => 1.day.from_now.to_date, :done_ratio => 0).behind_schedule?
612 assert !Issue.new(:start_date => nil, :due_date => 1.day.from_now.to_date, :done_ratio => 0).behind_schedule?
571 end
613 end
572
614
573 should "be false if the issue has no end_date" do
615 should "be false if the issue has no end_date" do
574 assert !Issue.new(:start_date => 1.day.from_now.to_date, :due_date => nil, :done_ratio => 0).behind_schedule?
616 assert !Issue.new(:start_date => 1.day.from_now.to_date, :due_date => nil, :done_ratio => 0).behind_schedule?
575 end
617 end
576
618
577 should "be false if the issue has more done than it's calendar time" do
619 should "be false if the issue has more done than it's calendar time" do
578 assert !Issue.new(:start_date => 50.days.ago.to_date, :due_date => 50.days.from_now.to_date, :done_ratio => 90).behind_schedule?
620 assert !Issue.new(:start_date => 50.days.ago.to_date, :due_date => 50.days.from_now.to_date, :done_ratio => 90).behind_schedule?
579 end
621 end
580
622
581 should "be true if the issue hasn't been started at all" do
623 should "be true if the issue hasn't been started at all" do
582 assert Issue.new(:start_date => 1.day.ago.to_date, :due_date => 1.day.from_now.to_date, :done_ratio => 0).behind_schedule?
624 assert Issue.new(:start_date => 1.day.ago.to_date, :due_date => 1.day.from_now.to_date, :done_ratio => 0).behind_schedule?
583 end
625 end
584
626
585 should "be true if the issue has used more calendar time than it's done ratio" do
627 should "be true if the issue has used more calendar time than it's done ratio" do
586 assert Issue.new(:start_date => 100.days.ago.to_date, :due_date => Date.today, :done_ratio => 90).behind_schedule?
628 assert Issue.new(:start_date => 100.days.ago.to_date, :due_date => Date.today, :done_ratio => 90).behind_schedule?
587 end
629 end
588 end
630 end
589
631
590 context "#assignable_users" do
632 context "#assignable_users" do
591 should "be Users" do
633 should "be Users" do
592 assert_kind_of User, Issue.find(1).assignable_users.first
634 assert_kind_of User, Issue.find(1).assignable_users.first
593 end
635 end
594
636
595 should "include the issue author" do
637 should "include the issue author" do
596 project = Project.find(1)
638 project = Project.find(1)
597 non_project_member = User.generate!
639 non_project_member = User.generate!
598 issue = Issue.generate_for_project!(project, :author => non_project_member)
640 issue = Issue.generate_for_project!(project, :author => non_project_member)
599
641
600 assert issue.assignable_users.include?(non_project_member)
642 assert issue.assignable_users.include?(non_project_member)
601 end
643 end
602
644
603 should "not show the issue author twice" do
645 should "not show the issue author twice" do
604 assignable_user_ids = Issue.find(1).assignable_users.collect(&:id)
646 assignable_user_ids = Issue.find(1).assignable_users.collect(&:id)
605 assert_equal 2, assignable_user_ids.length
647 assert_equal 2, assignable_user_ids.length
606
648
607 assignable_user_ids.each do |user_id|
649 assignable_user_ids.each do |user_id|
608 assert_equal 1, assignable_user_ids.select {|i| i == user_id}.length, "User #{user_id} appears more or less than once"
650 assert_equal 1, assignable_user_ids.select {|i| i == user_id}.length, "User #{user_id} appears more or less than once"
609 end
651 end
610 end
652 end
611 end
653 end
612
654
613 def test_create_should_send_email_notification
655 def test_create_should_send_email_notification
614 ActionMailer::Base.deliveries.clear
656 ActionMailer::Base.deliveries.clear
615 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')
657 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')
616
658
617 assert issue.save
659 assert issue.save
618 assert_equal 1, ActionMailer::Base.deliveries.size
660 assert_equal 1, ActionMailer::Base.deliveries.size
619 end
661 end
620
662
621 def test_stale_issue_should_not_send_email_notification
663 def test_stale_issue_should_not_send_email_notification
622 ActionMailer::Base.deliveries.clear
664 ActionMailer::Base.deliveries.clear
623 issue = Issue.find(1)
665 issue = Issue.find(1)
624 stale = Issue.find(1)
666 stale = Issue.find(1)
625
667
626 issue.init_journal(User.find(1))
668 issue.init_journal(User.find(1))
627 issue.subject = 'Subjet update'
669 issue.subject = 'Subjet update'
628 assert issue.save
670 assert issue.save
629 assert_equal 1, ActionMailer::Base.deliveries.size
671 assert_equal 1, ActionMailer::Base.deliveries.size
630 ActionMailer::Base.deliveries.clear
672 ActionMailer::Base.deliveries.clear
631
673
632 stale.init_journal(User.find(1))
674 stale.init_journal(User.find(1))
633 stale.subject = 'Another subjet update'
675 stale.subject = 'Another subjet update'
634 assert_raise ActiveRecord::StaleObjectError do
676 assert_raise ActiveRecord::StaleObjectError do
635 stale.save
677 stale.save
636 end
678 end
637 assert ActionMailer::Base.deliveries.empty?
679 assert ActionMailer::Base.deliveries.empty?
638 end
680 end
639
681
640 def test_journalized_description
682 def test_journalized_description
641 IssueCustomField.delete_all
683 IssueCustomField.delete_all
642
684
643 i = Issue.first
685 i = Issue.first
644 old_description = i.description
686 old_description = i.description
645 new_description = "This is the new description"
687 new_description = "This is the new description"
646
688
647 i.init_journal(User.find(2))
689 i.init_journal(User.find(2))
648 i.description = new_description
690 i.description = new_description
649 assert_difference 'Journal.count', 1 do
691 assert_difference 'Journal.count', 1 do
650 assert_difference 'JournalDetail.count', 1 do
692 assert_difference 'JournalDetail.count', 1 do
651 i.save!
693 i.save!
652 end
694 end
653 end
695 end
654
696
655 detail = JournalDetail.first(:order => 'id DESC')
697 detail = JournalDetail.first(:order => 'id DESC')
656 assert_equal i, detail.journal.journalized
698 assert_equal i, detail.journal.journalized
657 assert_equal 'attr', detail.property
699 assert_equal 'attr', detail.property
658 assert_equal 'description', detail.prop_key
700 assert_equal 'description', detail.prop_key
659 assert_equal old_description, detail.old_value
701 assert_equal old_description, detail.old_value
660 assert_equal new_description, detail.value
702 assert_equal new_description, detail.value
661 end
703 end
662
704
663 def test_saving_twice_should_not_duplicate_journal_details
705 def test_saving_twice_should_not_duplicate_journal_details
664 i = Issue.find(:first)
706 i = Issue.find(:first)
665 i.init_journal(User.find(2), 'Some notes')
707 i.init_journal(User.find(2), 'Some notes')
666 # initial changes
708 # initial changes
667 i.subject = 'New subject'
709 i.subject = 'New subject'
668 i.done_ratio = i.done_ratio + 10
710 i.done_ratio = i.done_ratio + 10
669 assert_difference 'Journal.count' do
711 assert_difference 'Journal.count' do
670 assert i.save
712 assert i.save
671 end
713 end
672 # 1 more change
714 # 1 more change
673 i.priority = IssuePriority.find(:first, :conditions => ["id <> ?", i.priority_id])
715 i.priority = IssuePriority.find(:first, :conditions => ["id <> ?", i.priority_id])
674 assert_no_difference 'Journal.count' do
716 assert_no_difference 'Journal.count' do
675 assert_difference 'JournalDetail.count', 1 do
717 assert_difference 'JournalDetail.count', 1 do
676 i.save
718 i.save
677 end
719 end
678 end
720 end
679 # no more change
721 # no more change
680 assert_no_difference 'Journal.count' do
722 assert_no_difference 'Journal.count' do
681 assert_no_difference 'JournalDetail.count' do
723 assert_no_difference 'JournalDetail.count' do
682 i.save
724 i.save
683 end
725 end
684 end
726 end
685 end
727 end
686
728
687 def test_all_dependent_issues
729 def test_all_dependent_issues
688 IssueRelation.delete_all
730 IssueRelation.delete_all
689 assert IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_PRECEDES)
731 assert IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_PRECEDES)
690 assert IssueRelation.create!(:issue_from => Issue.find(2), :issue_to => Issue.find(3), :relation_type => IssueRelation::TYPE_PRECEDES)
732 assert IssueRelation.create!(:issue_from => Issue.find(2), :issue_to => Issue.find(3), :relation_type => IssueRelation::TYPE_PRECEDES)
691 assert IssueRelation.create!(:issue_from => Issue.find(3), :issue_to => Issue.find(8), :relation_type => IssueRelation::TYPE_PRECEDES)
733 assert IssueRelation.create!(:issue_from => Issue.find(3), :issue_to => Issue.find(8), :relation_type => IssueRelation::TYPE_PRECEDES)
692
734
693 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
735 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
694 end
736 end
695
737
696 def test_all_dependent_issues_with_persistent_circular_dependency
738 def test_all_dependent_issues_with_persistent_circular_dependency
697 IssueRelation.delete_all
739 IssueRelation.delete_all
698 assert IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_PRECEDES)
740 assert IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_PRECEDES)
699 assert IssueRelation.create!(:issue_from => Issue.find(2), :issue_to => Issue.find(3), :relation_type => IssueRelation::TYPE_PRECEDES)
741 assert IssueRelation.create!(:issue_from => Issue.find(2), :issue_to => Issue.find(3), :relation_type => IssueRelation::TYPE_PRECEDES)
700 # Validation skipping
742 # Validation skipping
701 assert IssueRelation.new(:issue_from => Issue.find(3), :issue_to => Issue.find(1), :relation_type => IssueRelation::TYPE_PRECEDES).save(false)
743 assert IssueRelation.new(:issue_from => Issue.find(3), :issue_to => Issue.find(1), :relation_type => IssueRelation::TYPE_PRECEDES).save(false)
702
744
703 assert_equal [2, 3], Issue.find(1).all_dependent_issues.collect(&:id).sort
745 assert_equal [2, 3], Issue.find(1).all_dependent_issues.collect(&:id).sort
704 end
746 end
705
747
706 def test_all_dependent_issues_with_persistent_multiple_circular_dependencies
748 def test_all_dependent_issues_with_persistent_multiple_circular_dependencies
707 IssueRelation.delete_all
749 IssueRelation.delete_all
708 assert IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_RELATES)
750 assert IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_RELATES)
709 assert IssueRelation.create!(:issue_from => Issue.find(2), :issue_to => Issue.find(3), :relation_type => IssueRelation::TYPE_RELATES)
751 assert IssueRelation.create!(:issue_from => Issue.find(2), :issue_to => Issue.find(3), :relation_type => IssueRelation::TYPE_RELATES)
710 assert IssueRelation.create!(:issue_from => Issue.find(3), :issue_to => Issue.find(8), :relation_type => IssueRelation::TYPE_RELATES)
752 assert IssueRelation.create!(:issue_from => Issue.find(3), :issue_to => Issue.find(8), :relation_type => IssueRelation::TYPE_RELATES)
711 # Validation skipping
753 # Validation skipping
712 assert IssueRelation.new(:issue_from => Issue.find(8), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_RELATES).save(false)
754 assert IssueRelation.new(:issue_from => Issue.find(8), :issue_to => Issue.find(2), :relation_type => IssueRelation::TYPE_RELATES).save(false)
713 assert IssueRelation.new(:issue_from => Issue.find(3), :issue_to => Issue.find(1), :relation_type => IssueRelation::TYPE_RELATES).save(false)
755 assert IssueRelation.new(:issue_from => Issue.find(3), :issue_to => Issue.find(1), :relation_type => IssueRelation::TYPE_RELATES).save(false)
714
756
715 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
757 assert_equal [2, 3, 8], Issue.find(1).all_dependent_issues.collect(&:id).sort
716 end
758 end
717
759
718 context "#done_ratio" do
760 context "#done_ratio" do
719 setup do
761 setup do
720 @issue = Issue.find(1)
762 @issue = Issue.find(1)
721 @issue_status = IssueStatus.find(1)
763 @issue_status = IssueStatus.find(1)
722 @issue_status.update_attribute(:default_done_ratio, 50)
764 @issue_status.update_attribute(:default_done_ratio, 50)
723 @issue2 = Issue.find(2)
765 @issue2 = Issue.find(2)
724 @issue_status2 = IssueStatus.find(2)
766 @issue_status2 = IssueStatus.find(2)
725 @issue_status2.update_attribute(:default_done_ratio, 0)
767 @issue_status2.update_attribute(:default_done_ratio, 0)
726 end
768 end
727
769
728 context "with Setting.issue_done_ratio using the issue_field" do
770 context "with Setting.issue_done_ratio using the issue_field" do
729 setup do
771 setup do
730 Setting.issue_done_ratio = 'issue_field'
772 Setting.issue_done_ratio = 'issue_field'
731 end
773 end
732
774
733 should "read the issue's field" do
775 should "read the issue's field" do
734 assert_equal 0, @issue.done_ratio
776 assert_equal 0, @issue.done_ratio
735 assert_equal 30, @issue2.done_ratio
777 assert_equal 30, @issue2.done_ratio
736 end
778 end
737 end
779 end
738
780
739 context "with Setting.issue_done_ratio using the issue_status" do
781 context "with Setting.issue_done_ratio using the issue_status" do
740 setup do
782 setup do
741 Setting.issue_done_ratio = 'issue_status'
783 Setting.issue_done_ratio = 'issue_status'
742 end
784 end
743
785
744 should "read the Issue Status's default done ratio" do
786 should "read the Issue Status's default done ratio" do
745 assert_equal 50, @issue.done_ratio
787 assert_equal 50, @issue.done_ratio
746 assert_equal 0, @issue2.done_ratio
788 assert_equal 0, @issue2.done_ratio
747 end
789 end
748 end
790 end
749 end
791 end
750
792
751 context "#update_done_ratio_from_issue_status" do
793 context "#update_done_ratio_from_issue_status" do
752 setup do
794 setup do
753 @issue = Issue.find(1)
795 @issue = Issue.find(1)
754 @issue_status = IssueStatus.find(1)
796 @issue_status = IssueStatus.find(1)
755 @issue_status.update_attribute(:default_done_ratio, 50)
797 @issue_status.update_attribute(:default_done_ratio, 50)
756 @issue2 = Issue.find(2)
798 @issue2 = Issue.find(2)
757 @issue_status2 = IssueStatus.find(2)
799 @issue_status2 = IssueStatus.find(2)
758 @issue_status2.update_attribute(:default_done_ratio, 0)
800 @issue_status2.update_attribute(:default_done_ratio, 0)
759 end
801 end
760
802
761 context "with Setting.issue_done_ratio using the issue_field" do
803 context "with Setting.issue_done_ratio using the issue_field" do
762 setup do
804 setup do
763 Setting.issue_done_ratio = 'issue_field'
805 Setting.issue_done_ratio = 'issue_field'
764 end
806 end
765
807
766 should "not change the issue" do
808 should "not change the issue" do
767 @issue.update_done_ratio_from_issue_status
809 @issue.update_done_ratio_from_issue_status
768 @issue2.update_done_ratio_from_issue_status
810 @issue2.update_done_ratio_from_issue_status
769
811
770 assert_equal 0, @issue.read_attribute(:done_ratio)
812 assert_equal 0, @issue.read_attribute(:done_ratio)
771 assert_equal 30, @issue2.read_attribute(:done_ratio)
813 assert_equal 30, @issue2.read_attribute(:done_ratio)
772 end
814 end
773 end
815 end
774
816
775 context "with Setting.issue_done_ratio using the issue_status" do
817 context "with Setting.issue_done_ratio using the issue_status" do
776 setup do
818 setup do
777 Setting.issue_done_ratio = 'issue_status'
819 Setting.issue_done_ratio = 'issue_status'
778 end
820 end
779
821
780 should "change the issue's done ratio" do
822 should "change the issue's done ratio" do
781 @issue.update_done_ratio_from_issue_status
823 @issue.update_done_ratio_from_issue_status
782 @issue2.update_done_ratio_from_issue_status
824 @issue2.update_done_ratio_from_issue_status
783
825
784 assert_equal 50, @issue.read_attribute(:done_ratio)
826 assert_equal 50, @issue.read_attribute(:done_ratio)
785 assert_equal 0, @issue2.read_attribute(:done_ratio)
827 assert_equal 0, @issue2.read_attribute(:done_ratio)
786 end
828 end
787 end
829 end
788 end
830 end
789
831
790 test "#by_tracker" do
832 test "#by_tracker" do
791 User.current = User.anonymous
833 User.current = User.anonymous
792 groups = Issue.by_tracker(Project.find(1))
834 groups = Issue.by_tracker(Project.find(1))
793 assert_equal 3, groups.size
835 assert_equal 3, groups.size
794 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
836 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
795 end
837 end
796
838
797 test "#by_version" do
839 test "#by_version" do
798 User.current = User.anonymous
840 User.current = User.anonymous
799 groups = Issue.by_version(Project.find(1))
841 groups = Issue.by_version(Project.find(1))
800 assert_equal 3, groups.size
842 assert_equal 3, groups.size
801 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
843 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
802 end
844 end
803
845
804 test "#by_priority" do
846 test "#by_priority" do
805 User.current = User.anonymous
847 User.current = User.anonymous
806 groups = Issue.by_priority(Project.find(1))
848 groups = Issue.by_priority(Project.find(1))
807 assert_equal 4, groups.size
849 assert_equal 4, groups.size
808 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
850 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
809 end
851 end
810
852
811 test "#by_category" do
853 test "#by_category" do
812 User.current = User.anonymous
854 User.current = User.anonymous
813 groups = Issue.by_category(Project.find(1))
855 groups = Issue.by_category(Project.find(1))
814 assert_equal 2, groups.size
856 assert_equal 2, groups.size
815 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
857 assert_equal 3, groups.inject(0) {|sum, group| sum + group['total'].to_i}
816 end
858 end
817
859
818 test "#by_assigned_to" do
860 test "#by_assigned_to" do
819 User.current = User.anonymous
861 User.current = User.anonymous
820 groups = Issue.by_assigned_to(Project.find(1))
862 groups = Issue.by_assigned_to(Project.find(1))
821 assert_equal 2, groups.size
863 assert_equal 2, groups.size
822 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
864 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
823 end
865 end
824
866
825 test "#by_author" do
867 test "#by_author" do
826 User.current = User.anonymous
868 User.current = User.anonymous
827 groups = Issue.by_author(Project.find(1))
869 groups = Issue.by_author(Project.find(1))
828 assert_equal 4, groups.size
870 assert_equal 4, groups.size
829 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
871 assert_equal 7, groups.inject(0) {|sum, group| sum + group['total'].to_i}
830 end
872 end
831
873
832 test "#by_subproject" do
874 test "#by_subproject" do
833 User.current = User.anonymous
875 User.current = User.anonymous
834 groups = Issue.by_subproject(Project.find(1))
876 groups = Issue.by_subproject(Project.find(1))
835 # Private descendant not visible
877 # Private descendant not visible
836 assert_equal 1, groups.size
878 assert_equal 1, groups.size
837 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
879 assert_equal 2, groups.inject(0) {|sum, group| sum + group['total'].to_i}
838 end
880 end
839
881
840
882
841 context ".allowed_target_projects_on_move" do
883 context ".allowed_target_projects_on_move" do
842 should "return all active projects for admin users" do
884 should "return all active projects for admin users" do
843 User.current = User.find(1)
885 User.current = User.find(1)
844 assert_equal Project.active.count, Issue.allowed_target_projects_on_move.size
886 assert_equal Project.active.count, Issue.allowed_target_projects_on_move.size
845 end
887 end
846
888
847 should "return allowed projects for non admin users" do
889 should "return allowed projects for non admin users" do
848 User.current = User.find(2)
890 User.current = User.find(2)
849 Role.non_member.remove_permission! :move_issues
891 Role.non_member.remove_permission! :move_issues
850 assert_equal 3, Issue.allowed_target_projects_on_move.size
892 assert_equal 3, Issue.allowed_target_projects_on_move.size
851
893
852 Role.non_member.add_permission! :move_issues
894 Role.non_member.add_permission! :move_issues
853 assert_equal Project.active.count, Issue.allowed_target_projects_on_move.size
895 assert_equal Project.active.count, Issue.allowed_target_projects_on_move.size
854 end
896 end
855 end
897 end
856
898
857 def test_recently_updated_with_limit_scopes
899 def test_recently_updated_with_limit_scopes
858 #should return the last updated issue
900 #should return the last updated issue
859 assert_equal 1, Issue.recently_updated.with_limit(1).length
901 assert_equal 1, Issue.recently_updated.with_limit(1).length
860 assert_equal Issue.find(:first, :order => "updated_on DESC"), Issue.recently_updated.with_limit(1).first
902 assert_equal Issue.find(:first, :order => "updated_on DESC"), Issue.recently_updated.with_limit(1).first
861 end
903 end
862
904
863 def test_on_active_projects_scope
905 def test_on_active_projects_scope
864 assert Project.find(2).archive
906 assert Project.find(2).archive
865
907
866 before = Issue.on_active_project.length
908 before = Issue.on_active_project.length
867 # test inclusion to results
909 # test inclusion to results
868 issue = Issue.generate_for_project!(Project.find(1), :tracker => Project.find(2).trackers.first)
910 issue = Issue.generate_for_project!(Project.find(1), :tracker => Project.find(2).trackers.first)
869 assert_equal before + 1, Issue.on_active_project.length
911 assert_equal before + 1, Issue.on_active_project.length
870
912
871 # Move to an archived project
913 # Move to an archived project
872 issue.project = Project.find(2)
914 issue.project = Project.find(2)
873 assert issue.save
915 assert issue.save
874 assert_equal before, Issue.on_active_project.length
916 assert_equal before, Issue.on_active_project.length
875 end
917 end
876
918
877 context "Issue#recipients" do
919 context "Issue#recipients" do
878 setup do
920 setup do
879 @project = Project.find(1)
921 @project = Project.find(1)
880 @author = User.generate_with_protected!
922 @author = User.generate_with_protected!
881 @assignee = User.generate_with_protected!
923 @assignee = User.generate_with_protected!
882 @issue = Issue.generate_for_project!(@project, :assigned_to => @assignee, :author => @author)
924 @issue = Issue.generate_for_project!(@project, :assigned_to => @assignee, :author => @author)
883 end
925 end
884
926
885 should "include project recipients" do
927 should "include project recipients" do
886 assert @project.recipients.present?
928 assert @project.recipients.present?
887 @project.recipients.each do |project_recipient|
929 @project.recipients.each do |project_recipient|
888 assert @issue.recipients.include?(project_recipient)
930 assert @issue.recipients.include?(project_recipient)
889 end
931 end
890 end
932 end
891
933
892 should "include the author if the author is active" do
934 should "include the author if the author is active" do
893 assert @issue.author, "No author set for Issue"
935 assert @issue.author, "No author set for Issue"
894 assert @issue.recipients.include?(@issue.author.mail)
936 assert @issue.recipients.include?(@issue.author.mail)
895 end
937 end
896
938
897 should "include the assigned to user if the assigned to user is active" do
939 should "include the assigned to user if the assigned to user is active" do
898 assert @issue.assigned_to, "No assigned_to set for Issue"
940 assert @issue.assigned_to, "No assigned_to set for Issue"
899 assert @issue.recipients.include?(@issue.assigned_to.mail)
941 assert @issue.recipients.include?(@issue.assigned_to.mail)
900 end
942 end
901
943
902 should "not include users who opt out of all email" do
944 should "not include users who opt out of all email" do
903 @author.update_attribute(:mail_notification, :none)
945 @author.update_attribute(:mail_notification, :none)
904
946
905 assert !@issue.recipients.include?(@issue.author.mail)
947 assert !@issue.recipients.include?(@issue.author.mail)
906 end
948 end
907
949
908 should "not include the issue author if they are only notified of assigned issues" do
950 should "not include the issue author if they are only notified of assigned issues" do
909 @author.update_attribute(:mail_notification, :only_assigned)
951 @author.update_attribute(:mail_notification, :only_assigned)
910
952
911 assert !@issue.recipients.include?(@issue.author.mail)
953 assert !@issue.recipients.include?(@issue.author.mail)
912 end
954 end
913
955
914 should "not include the assigned user if they are only notified of owned issues" do
956 should "not include the assigned user if they are only notified of owned issues" do
915 @assignee.update_attribute(:mail_notification, :only_owner)
957 @assignee.update_attribute(:mail_notification, :only_owner)
916
958
917 assert !@issue.recipients.include?(@issue.assigned_to.mail)
959 assert !@issue.recipients.include?(@issue.assigned_to.mail)
918 end
960 end
919
961
920 end
962 end
921 end
963 end
General Comments 0
You need to be logged in to leave comments. Login now