##// END OF EJS Templates
Adds updated_by and last_updated_by filters on issues (#17720)....
Jean-Philippe Lang -
r15846:151a215ea45c
parent child
Show More
@@ -1,546 +1,575
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class IssueQuery < Query
19 19
20 20 self.queried_class = Issue
21 21 self.view_permission = :view_issues
22 22
23 23 self.available_columns = [
24 24 QueryColumn.new(:id, :sortable => "#{Issue.table_name}.id", :default_order => 'desc', :caption => '#', :frozen => true),
25 25 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
26 26 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
27 27 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
28 28 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
29 29 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
30 30 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
31 31 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
32 32 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
33 33 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
34 34 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
35 35 QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true),
36 36 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
37 37 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
38 38 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours", :totalable => true),
39 39 QueryColumn.new(:total_estimated_hours,
40 40 :sortable => "COALESCE((SELECT SUM(estimated_hours) FROM #{Issue.table_name} subtasks" +
41 41 " WHERE subtasks.root_id = #{Issue.table_name}.root_id AND subtasks.lft >= #{Issue.table_name}.lft AND subtasks.rgt <= #{Issue.table_name}.rgt), 0)",
42 42 :default_order => 'desc'),
43 43 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
44 44 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
45 45 QueryColumn.new(:closed_on, :sortable => "#{Issue.table_name}.closed_on", :default_order => 'desc'),
46 46 QueryColumn.new(:relations, :caption => :label_related_issues),
47 47 QueryColumn.new(:description, :inline => false)
48 48 ]
49 49
50 50 def initialize(attributes=nil, *args)
51 51 super attributes
52 52 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
53 53 end
54 54
55 55 def draw_relations
56 56 r = options[:draw_relations]
57 57 r.nil? || r == '1'
58 58 end
59 59
60 60 def draw_relations=(arg)
61 61 options[:draw_relations] = (arg == '0' ? '0' : nil)
62 62 end
63 63
64 64 def draw_progress_line
65 65 r = options[:draw_progress_line]
66 66 r == '1'
67 67 end
68 68
69 69 def draw_progress_line=(arg)
70 70 options[:draw_progress_line] = (arg == '1' ? '1' : nil)
71 71 end
72 72
73 73 def build_from_params(params)
74 74 super
75 75 self.draw_relations = params[:draw_relations] || (params[:query] && params[:query][:draw_relations])
76 76 self.draw_progress_line = params[:draw_progress_line] || (params[:query] && params[:query][:draw_progress_line])
77 77 self
78 78 end
79 79
80 80 def initialize_available_filters
81 81 add_available_filter "status_id",
82 82 :type => :list_status, :values => lambda { IssueStatus.sorted.collect{|s| [s.name, s.id.to_s] } }
83 83
84 84 add_available_filter("project_id",
85 85 :type => :list, :values => lambda { project_values }
86 86 ) if project.nil?
87 87
88 88 add_available_filter "tracker_id",
89 89 :type => :list, :values => trackers.collect{|s| [s.name, s.id.to_s] }
90 90
91 91 add_available_filter "priority_id",
92 92 :type => :list, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] }
93 93
94 94 add_available_filter("author_id",
95 95 :type => :list, :values => lambda { author_values }
96 96 )
97 97
98 98 add_available_filter("assigned_to_id",
99 99 :type => :list_optional, :values => lambda { assigned_to_values }
100 100 )
101 101
102 102 add_available_filter("member_of_group",
103 103 :type => :list_optional, :values => lambda { Group.givable.visible.collect {|g| [g.name, g.id.to_s] } }
104 104 )
105 105
106 106 add_available_filter("assigned_to_role",
107 107 :type => :list_optional, :values => lambda { Role.givable.collect {|r| [r.name, r.id.to_s] } }
108 108 )
109 109
110 110 add_available_filter "fixed_version_id",
111 111 :type => :list_optional, :values => lambda { fixed_version_values }
112 112
113 113 add_available_filter "fixed_version.due_date",
114 114 :type => :date,
115 115 :name => l(:label_attribute_of_fixed_version, :name => l(:field_effective_date))
116 116
117 117 add_available_filter "fixed_version.status",
118 118 :type => :list,
119 119 :name => l(:label_attribute_of_fixed_version, :name => l(:field_status)),
120 120 :values => Version::VERSION_STATUSES.map{|s| [l("version_status_#{s}"), s] }
121 121
122 122 add_available_filter "category_id",
123 123 :type => :list_optional,
124 124 :values => lambda { project.issue_categories.collect{|s| [s.name, s.id.to_s] } } if project
125 125
126 126 add_available_filter "subject", :type => :text
127 127 add_available_filter "description", :type => :text
128 128 add_available_filter "created_on", :type => :date_past
129 129 add_available_filter "updated_on", :type => :date_past
130 130 add_available_filter "closed_on", :type => :date_past
131 131 add_available_filter "start_date", :type => :date
132 132 add_available_filter "due_date", :type => :date
133 133 add_available_filter "estimated_hours", :type => :float
134 134 add_available_filter "done_ratio", :type => :integer
135 135
136 136 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
137 137 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
138 138 add_available_filter "is_private",
139 139 :type => :list,
140 140 :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]]
141 141 end
142 142
143 143 if User.current.logged?
144 144 add_available_filter "watcher_id",
145 145 :type => :list, :values => [["<< #{l(:label_me)} >>", "me"]]
146 146 end
147 147
148 148 if project && !project.leaf?
149 149 add_available_filter "subproject_id",
150 150 :type => :list_subprojects,
151 151 :values => lambda { subproject_values }
152 152 end
153 153
154 154
155 155 issue_custom_fields = project ? project.all_issue_custom_fields : IssueCustomField.where(:is_for_all => true)
156 156 add_custom_fields_filters(issue_custom_fields)
157 157
158 158 add_associations_custom_fields_filters :project, :author, :assigned_to, :fixed_version
159 159
160 160 IssueRelation::TYPES.each do |relation_type, options|
161 161 add_available_filter relation_type, :type => :relation, :label => options[:name], :values => lambda {all_projects_values}
162 162 end
163 163 add_available_filter "parent_id", :type => :tree, :label => :field_parent_issue
164 164 add_available_filter "child_id", :type => :tree, :label => :label_subtask_plural
165 165
166 166 add_available_filter "issue_id", :type => :integer, :label => :label_issue
167 167
168 add_available_filter("updated_by",
169 :type => :list, :values => lambda { author_values }
170 )
171
172 add_available_filter("last_updated_by",
173 :type => :list, :values => lambda { author_values }
174 )
175
168 176 Tracker.disabled_core_fields(trackers).each {|field|
169 177 delete_available_filter field
170 178 }
171 179 end
172 180
173 181 def available_columns
174 182 return @available_columns if @available_columns
175 183 @available_columns = self.class.available_columns.dup
176 184 @available_columns += (project ?
177 185 project.all_issue_custom_fields :
178 186 IssueCustomField
179 187 ).visible.collect {|cf| QueryCustomFieldColumn.new(cf) }
180 188
181 189 if User.current.allowed_to?(:view_time_entries, project, :global => true)
182 190 index = @available_columns.find_index {|column| column.name == :total_estimated_hours}
183 191 index = (index ? index + 1 : -1)
184 192 # insert the column after total_estimated_hours or at the end
185 193 @available_columns.insert index, QueryColumn.new(:spent_hours,
186 194 :sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id), 0)",
187 195 :default_order => 'desc',
188 196 :caption => :label_spent_time,
189 197 :totalable => true
190 198 )
191 199 @available_columns.insert index+1, QueryColumn.new(:total_spent_hours,
192 200 :sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} JOIN #{Issue.table_name} subtasks ON subtasks.id = #{TimeEntry.table_name}.issue_id" +
193 201 " WHERE subtasks.root_id = #{Issue.table_name}.root_id AND subtasks.lft >= #{Issue.table_name}.lft AND subtasks.rgt <= #{Issue.table_name}.rgt), 0)",
194 202 :default_order => 'desc',
195 203 :caption => :label_total_spent_time
196 204 )
197 205 end
198 206
199 207 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
200 208 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
201 209 @available_columns << QueryColumn.new(:is_private, :sortable => "#{Issue.table_name}.is_private")
202 210 end
203 211
204 212 disabled_fields = Tracker.disabled_core_fields(trackers).map {|field| field.sub(/_id$/, '')}
205 213 @available_columns.reject! {|column|
206 214 disabled_fields.include?(column.name.to_s)
207 215 }
208 216
209 217 @available_columns
210 218 end
211 219
212 220 def default_columns_names
213 221 @default_columns_names ||= begin
214 222 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
215 223
216 224 project.present? ? default_columns : [:project] | default_columns
217 225 end
218 226 end
219 227
220 228 def default_totalable_names
221 229 Setting.issue_list_default_totals.map(&:to_sym)
222 230 end
223 231
224 232 def base_scope
225 233 Issue.visible.joins(:status, :project).where(statement)
226 234 end
227 235
228 236 # Returns the issue count
229 237 def issue_count
230 238 base_scope.count
231 239 rescue ::ActiveRecord::StatementInvalid => e
232 240 raise StatementInvalid.new(e.message)
233 241 end
234 242
235 243 # Returns the issue count by group or nil if query is not grouped
236 244 def issue_count_by_group
237 245 grouped_query do |scope|
238 246 scope.count
239 247 end
240 248 end
241 249
242 250 # Returns sum of all the issue's estimated_hours
243 251 def total_for_estimated_hours(scope)
244 252 map_total(scope.sum(:estimated_hours)) {|t| t.to_f.round(2)}
245 253 end
246 254
247 255 # Returns sum of all the issue's time entries hours
248 256 def total_for_spent_hours(scope)
249 257 total = if group_by_column.try(:name) == :project
250 258 # TODO: remove this when https://github.com/rails/rails/issues/21922 is fixed
251 259 # We have to do a custom join without the time_entries.project_id column
252 260 # that would trigger a ambiguous column name error
253 261 scope.joins("JOIN (SELECT issue_id, hours FROM #{TimeEntry.table_name}) AS joined_time_entries ON joined_time_entries.issue_id = #{Issue.table_name}.id").
254 262 sum("joined_time_entries.hours")
255 263 else
256 264 scope.joins(:time_entries).sum("#{TimeEntry.table_name}.hours")
257 265 end
258 266 map_total(total) {|t| t.to_f.round(2)}
259 267 end
260 268
261 269 # Returns the issues
262 270 # Valid options are :order, :offset, :limit, :include, :conditions
263 271 def issues(options={})
264 272 order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
265 273
266 274 scope = Issue.visible.
267 275 joins(:status, :project).
268 276 where(statement).
269 277 includes(([:status, :project] + (options[:include] || [])).uniq).
270 278 where(options[:conditions]).
271 279 order(order_option).
272 280 joins(joins_for_order_statement(order_option.join(','))).
273 281 limit(options[:limit]).
274 282 offset(options[:offset])
275 283
276 284 scope = scope.preload([:tracker, :priority, :author, :assigned_to, :fixed_version, :category] & columns.map(&:name))
277 285 if has_custom_field_column?
278 286 scope = scope.preload(:custom_values)
279 287 end
280 288
281 289 issues = scope.to_a
282 290
283 291 if has_column?(:spent_hours)
284 292 Issue.load_visible_spent_hours(issues)
285 293 end
286 294 if has_column?(:total_spent_hours)
287 295 Issue.load_visible_total_spent_hours(issues)
288 296 end
289 297 if has_column?(:relations)
290 298 Issue.load_visible_relations(issues)
291 299 end
292 300 issues
293 301 rescue ::ActiveRecord::StatementInvalid => e
294 302 raise StatementInvalid.new(e.message)
295 303 end
296 304
297 305 # Returns the issues ids
298 306 def issue_ids(options={})
299 307 order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
300 308
301 309 Issue.visible.
302 310 joins(:status, :project).
303 311 where(statement).
304 312 includes(([:status, :project] + (options[:include] || [])).uniq).
305 313 references(([:status, :project] + (options[:include] || [])).uniq).
306 314 where(options[:conditions]).
307 315 order(order_option).
308 316 joins(joins_for_order_statement(order_option.join(','))).
309 317 limit(options[:limit]).
310 318 offset(options[:offset]).
311 319 pluck(:id)
312 320 rescue ::ActiveRecord::StatementInvalid => e
313 321 raise StatementInvalid.new(e.message)
314 322 end
315 323
316 324 # Returns the journals
317 325 # Valid options are :order, :offset, :limit
318 326 def journals(options={})
319 327 Journal.visible.
320 328 joins(:issue => [:project, :status]).
321 329 where(statement).
322 330 order(options[:order]).
323 331 limit(options[:limit]).
324 332 offset(options[:offset]).
325 333 preload(:details, :user, {:issue => [:project, :author, :tracker, :status]}).
326 334 to_a
327 335 rescue ::ActiveRecord::StatementInvalid => e
328 336 raise StatementInvalid.new(e.message)
329 337 end
330 338
331 339 # Returns the versions
332 340 # Valid options are :conditions
333 341 def versions(options={})
334 342 Version.visible.
335 343 where(project_statement).
336 344 where(options[:conditions]).
337 345 includes(:project).
338 346 references(:project).
339 347 to_a
340 348 rescue ::ActiveRecord::StatementInvalid => e
341 349 raise StatementInvalid.new(e.message)
342 350 end
343 351
352 def sql_for_updated_by_field(field, operator, value)
353 neg = (operator == '!' ? 'NOT' : '')
354 subquery = "SELECT 1 FROM #{Journal.table_name}" +
355 " WHERE #{Journal.table_name}.journalized_type='Issue' AND #{Journal.table_name}.journalized_id=#{Issue.table_name}.id" +
356 " AND (#{sql_for_field field, '=', value, Journal.table_name, 'user_id'})" +
357 " AND (#{Journal.visible_notes_condition(User.current, :skip_pre_condition => true)})"
358
359 "#{neg} EXISTS (#{subquery})"
360 end
361
362 def sql_for_last_updated_by_field(field, operator, value)
363 neg = (operator == '!' ? 'NOT' : '')
364 subquery = "SELECT 1 FROM #{Journal.table_name} sj" +
365 " WHERE sj.journalized_type='Issue' AND sj.journalized_id=#{Issue.table_name}.id AND (#{sql_for_field field, '=', value, 'sj', 'user_id'})" +
366 " AND sj.id = (SELECT MAX(#{Journal.table_name}.id) FROM #{Journal.table_name}" +
367 " WHERE #{Journal.table_name}.journalized_type='Issue' AND #{Journal.table_name}.journalized_id=#{Issue.table_name}.id" +
368 " AND (#{Journal.visible_notes_condition(User.current, :skip_pre_condition => true)}))"
369
370 "#{neg} EXISTS (#{subquery})"
371 end
372
344 373 def sql_for_watcher_id_field(field, operator, value)
345 374 db_table = Watcher.table_name
346 375 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
347 376 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
348 377 end
349 378
350 379 def sql_for_member_of_group_field(field, operator, value)
351 380 if operator == '*' # Any group
352 381 groups = Group.givable
353 382 operator = '=' # Override the operator since we want to find by assigned_to
354 383 elsif operator == "!*"
355 384 groups = Group.givable
356 385 operator = '!' # Override the operator since we want to find by assigned_to
357 386 else
358 387 groups = Group.where(:id => value).to_a
359 388 end
360 389 groups ||= []
361 390
362 391 members_of_groups = groups.inject([]) {|user_ids, group|
363 392 user_ids + group.user_ids + [group.id]
364 393 }.uniq.compact.sort.collect(&:to_s)
365 394
366 395 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
367 396 end
368 397
369 398 def sql_for_assigned_to_role_field(field, operator, value)
370 399 case operator
371 400 when "*", "!*" # Member / Not member
372 401 sw = operator == "!*" ? 'NOT' : ''
373 402 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
374 403 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
375 404 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
376 405 when "=", "!"
377 406 role_cond = value.any? ?
378 407 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{self.class.connection.quote_string(val)}'"}.join(",") + ")" :
379 408 "1=0"
380 409
381 410 sw = operator == "!" ? 'NOT' : ''
382 411 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
383 412 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
384 413 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
385 414 end
386 415 end
387 416
388 417 def sql_for_fixed_version_status_field(field, operator, value)
389 418 where = sql_for_field(field, operator, value, Version.table_name, "status")
390 419 version_ids = versions(:conditions => [where]).map(&:id)
391 420
392 421 nl = operator == "!" ? "#{Issue.table_name}.fixed_version_id IS NULL OR" : ''
393 422 "(#{nl} #{sql_for_field("fixed_version_id", "=", version_ids, Issue.table_name, "fixed_version_id")})"
394 423 end
395 424
396 425 def sql_for_fixed_version_due_date_field(field, operator, value)
397 426 where = sql_for_field(field, operator, value, Version.table_name, "effective_date")
398 427 version_ids = versions(:conditions => [where]).map(&:id)
399 428
400 429 nl = operator == "!*" ? "#{Issue.table_name}.fixed_version_id IS NULL OR" : ''
401 430 "(#{nl} #{sql_for_field("fixed_version_id", "=", version_ids, Issue.table_name, "fixed_version_id")})"
402 431 end
403 432
404 433 def sql_for_is_private_field(field, operator, value)
405 434 op = (operator == "=" ? 'IN' : 'NOT IN')
406 435 va = value.map {|v| v == '0' ? self.class.connection.quoted_false : self.class.connection.quoted_true}.uniq.join(',')
407 436
408 437 "#{Issue.table_name}.is_private #{op} (#{va})"
409 438 end
410 439
411 440 def sql_for_parent_id_field(field, operator, value)
412 441 case operator
413 442 when "="
414 443 "#{Issue.table_name}.parent_id = #{value.first.to_i}"
415 444 when "~"
416 445 root_id, lft, rgt = Issue.where(:id => value.first.to_i).pluck(:root_id, :lft, :rgt).first
417 446 if root_id && lft && rgt
418 447 "#{Issue.table_name}.root_id = #{root_id} AND #{Issue.table_name}.lft > #{lft} AND #{Issue.table_name}.rgt < #{rgt}"
419 448 else
420 449 "1=0"
421 450 end
422 451 when "!*"
423 452 "#{Issue.table_name}.parent_id IS NULL"
424 453 when "*"
425 454 "#{Issue.table_name}.parent_id IS NOT NULL"
426 455 end
427 456 end
428 457
429 458 def sql_for_child_id_field(field, operator, value)
430 459 case operator
431 460 when "="
432 461 parent_id = Issue.where(:id => value.first.to_i).pluck(:parent_id).first
433 462 if parent_id
434 463 "#{Issue.table_name}.id = #{parent_id}"
435 464 else
436 465 "1=0"
437 466 end
438 467 when "~"
439 468 root_id, lft, rgt = Issue.where(:id => value.first.to_i).pluck(:root_id, :lft, :rgt).first
440 469 if root_id && lft && rgt
441 470 "#{Issue.table_name}.root_id = #{root_id} AND #{Issue.table_name}.lft < #{lft} AND #{Issue.table_name}.rgt > #{rgt}"
442 471 else
443 472 "1=0"
444 473 end
445 474 when "!*"
446 475 "#{Issue.table_name}.rgt - #{Issue.table_name}.lft = 1"
447 476 when "*"
448 477 "#{Issue.table_name}.rgt - #{Issue.table_name}.lft > 1"
449 478 end
450 479 end
451 480
452 481 def sql_for_updated_on_field(field, operator, value)
453 482 case operator
454 483 when "!*"
455 484 "#{Issue.table_name}.updated_on = #{Issue.table_name}.created_on"
456 485 when "*"
457 486 "#{Issue.table_name}.updated_on > #{Issue.table_name}.created_on"
458 487 else
459 488 sql_for_field("updated_on", operator, value, Issue.table_name, "updated_on")
460 489 end
461 490 end
462 491
463 492 def sql_for_issue_id_field(field, operator, value)
464 493 if operator == "="
465 494 # accepts a comma separated list of ids
466 495 ids = value.first.to_s.scan(/\d+/).map(&:to_i)
467 496 if ids.present?
468 497 "#{Issue.table_name}.id IN (#{ids.join(",")})"
469 498 else
470 499 "1=0"
471 500 end
472 501 else
473 502 sql_for_field("id", operator, value, Issue.table_name, "id")
474 503 end
475 504 end
476 505
477 506 def sql_for_relations(field, operator, value, options={})
478 507 relation_options = IssueRelation::TYPES[field]
479 508 return relation_options unless relation_options
480 509
481 510 relation_type = field
482 511 join_column, target_join_column = "issue_from_id", "issue_to_id"
483 512 if relation_options[:reverse] || options[:reverse]
484 513 relation_type = relation_options[:reverse] || relation_type
485 514 join_column, target_join_column = target_join_column, join_column
486 515 end
487 516
488 517 sql = case operator
489 518 when "*", "!*"
490 519 op = (operator == "*" ? 'IN' : 'NOT IN')
491 520 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{self.class.connection.quote_string(relation_type)}')"
492 521 when "=", "!"
493 522 op = (operator == "=" ? 'IN' : 'NOT IN')
494 523 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{self.class.connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = #{value.first.to_i})"
495 524 when "=p", "=!p", "!p"
496 525 op = (operator == "!p" ? 'NOT IN' : 'IN')
497 526 comp = (operator == "=!p" ? '<>' : '=')
498 527 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{self.class.connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.project_id #{comp} #{value.first.to_i})"
499 528 when "*o", "!o"
500 529 op = (operator == "!o" ? 'NOT IN' : 'IN')
501 530 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{self.class.connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{self.class.connection.quoted_false}))"
502 531 end
503 532
504 533 if relation_options[:sym] == field && !options[:reverse]
505 534 sqls = [sql, sql_for_relations(field, operator, value, :reverse => true)]
506 535 sql = sqls.join(["!", "!*", "!p"].include?(operator) ? " AND " : " OR ")
507 536 end
508 537 "(#{sql})"
509 538 end
510 539
511 540 def find_assigned_to_id_filter_values(values)
512 541 Principal.visible.where(:id => values).map {|p| [p.name, p.id.to_s]}
513 542 end
514 543 alias :find_author_id_filter_values :find_assigned_to_id_filter_values
515 544
516 545 IssueRelation::TYPES.keys.each do |relation_type|
517 546 alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations
518 547 end
519 548
520 549 def joins_for_order_statement(order_options)
521 550 joins = [super]
522 551
523 552 if order_options
524 553 if order_options.include?('authors')
525 554 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{queried_table_name}.author_id"
526 555 end
527 556 if order_options.include?('users')
528 557 joins << "LEFT OUTER JOIN #{User.table_name} ON #{User.table_name}.id = #{queried_table_name}.assigned_to_id"
529 558 end
530 559 if order_options.include?('versions')
531 560 joins << "LEFT OUTER JOIN #{Version.table_name} ON #{Version.table_name}.id = #{queried_table_name}.fixed_version_id"
532 561 end
533 562 if order_options.include?('issue_categories')
534 563 joins << "LEFT OUTER JOIN #{IssueCategory.table_name} ON #{IssueCategory.table_name}.id = #{queried_table_name}.category_id"
535 564 end
536 565 if order_options.include?('trackers')
537 566 joins << "LEFT OUTER JOIN #{Tracker.table_name} ON #{Tracker.table_name}.id = #{queried_table_name}.tracker_id"
538 567 end
539 568 if order_options.include?('enumerations')
540 569 joins << "LEFT OUTER JOIN #{IssuePriority.table_name} ON #{IssuePriority.table_name}.id = #{queried_table_name}.priority_id"
541 570 end
542 571 end
543 572
544 573 joins.any? ? joins.join(' ') : nil
545 574 end
546 575 end
@@ -1,321 +1,328
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Journal < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20
21 21 belongs_to :journalized, :polymorphic => true
22 22 # added as a quick fix to allow eager loading of the polymorphic association
23 23 # since always associated to an issue, for now
24 24 belongs_to :issue, :foreign_key => :journalized_id
25 25
26 26 belongs_to :user
27 27 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all, :inverse_of => :journal
28 28 attr_accessor :indice
29 29 attr_protected :id
30 30
31 31 acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
32 32 :description => :notes,
33 33 :author => :user,
34 34 :group => :issue,
35 35 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
36 36 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
37 37
38 38 acts_as_activity_provider :type => 'issues',
39 39 :author_key => :user_id,
40 40 :scope => preload({:issue => :project}, :user).
41 41 joins("LEFT OUTER JOIN #{JournalDetail.table_name} ON #{JournalDetail.table_name}.journal_id = #{Journal.table_name}.id").
42 42 where("#{Journal.table_name}.journalized_type = 'Issue' AND" +
43 43 " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')").distinct
44 44
45 45 before_create :split_private_notes
46 46 after_create :send_notification
47 47
48 48 scope :visible, lambda {|*args|
49 49 user = args.shift || User.current
50 private_notes_condition = Project.allowed_to_condition(user, :view_private_notes, *args)
50 options = args.shift || {}
51
51 52 joins(:issue => :project).
52 where(Issue.visible_condition(user, *args)).
53 where("(#{Journal.table_name}.private_notes = ? OR #{Journal.table_name}.user_id = ? OR (#{private_notes_condition}))", false, user.id)
53 where(Issue.visible_condition(user, options)).
54 where(Journal.visible_notes_condition(user, :skip_pre_condition => true))
54 55 }
55 56
56 57 safe_attributes 'notes',
57 58 :if => lambda {|journal, user| journal.new_record? || journal.editable_by?(user)}
58 59 safe_attributes 'private_notes',
59 60 :if => lambda {|journal, user| user.allowed_to?(:set_notes_private, journal.project)}
60 61
62 # Returns a SQL condition to filter out journals with notes that are not visible to user
63 def self.visible_notes_condition(user=User.current, options={})
64 private_notes_permission = Project.allowed_to_condition(user, :view_private_notes, options)
65 sanitize_sql_for_conditions(["(#{table_name}.private_notes = ? OR #{table_name}.user_id = ? OR (#{private_notes_permission}))", false, user.id])
66 end
67
61 68 def initialize(*args)
62 69 super
63 70 if journalized
64 71 if journalized.new_record?
65 72 self.notify = false
66 73 else
67 74 start
68 75 end
69 76 end
70 77 end
71 78
72 79 def save(*args)
73 80 journalize_changes
74 81 # Do not save an empty journal
75 82 (details.empty? && notes.blank?) ? false : super
76 83 end
77 84
78 85 # Returns journal details that are visible to user
79 86 def visible_details(user=User.current)
80 87 details.select do |detail|
81 88 if detail.property == 'cf'
82 89 detail.custom_field && detail.custom_field.visible_by?(project, user)
83 90 elsif detail.property == 'relation'
84 91 Issue.find_by_id(detail.value || detail.old_value).try(:visible?, user)
85 92 else
86 93 true
87 94 end
88 95 end
89 96 end
90 97
91 98 def each_notification(users, &block)
92 99 if users.any?
93 100 users_by_details_visibility = users.group_by do |user|
94 101 visible_details(user)
95 102 end
96 103 users_by_details_visibility.each do |visible_details, users|
97 104 if notes? || visible_details.any?
98 105 yield(users)
99 106 end
100 107 end
101 108 end
102 109 end
103 110
104 111 # Returns the JournalDetail for the given attribute, or nil if the attribute
105 112 # was not updated
106 113 def detail_for_attribute(attribute)
107 114 details.detect {|detail| detail.prop_key == attribute}
108 115 end
109 116
110 117 # Returns the new status if the journal contains a status change, otherwise nil
111 118 def new_status
112 119 s = new_value_for('status_id')
113 120 s ? IssueStatus.find_by_id(s.to_i) : nil
114 121 end
115 122
116 123 def new_value_for(prop)
117 124 detail_for_attribute(prop).try(:value)
118 125 end
119 126
120 127 def editable_by?(usr)
121 128 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
122 129 end
123 130
124 131 def project
125 132 journalized.respond_to?(:project) ? journalized.project : nil
126 133 end
127 134
128 135 def attachments
129 136 journalized.respond_to?(:attachments) ? journalized.attachments : []
130 137 end
131 138
132 139 # Returns a string of css classes
133 140 def css_classes
134 141 s = 'journal'
135 142 s << ' has-notes' unless notes.blank?
136 143 s << ' has-details' unless details.blank?
137 144 s << ' private-notes' if private_notes?
138 145 s
139 146 end
140 147
141 148 def notify?
142 149 @notify != false
143 150 end
144 151
145 152 def notify=(arg)
146 153 @notify = arg
147 154 end
148 155
149 156 def notified_users
150 157 notified = journalized.notified_users
151 158 if private_notes?
152 159 notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
153 160 end
154 161 notified
155 162 end
156 163
157 164 def recipients
158 165 notified_users.map(&:mail)
159 166 end
160 167
161 168 def notified_watchers
162 169 notified = journalized.notified_watchers
163 170 if private_notes?
164 171 notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
165 172 end
166 173 notified
167 174 end
168 175
169 176 def watcher_recipients
170 177 notified_watchers.map(&:mail)
171 178 end
172 179
173 180 # Sets @custom_field instance variable on journals details using a single query
174 181 def self.preload_journals_details_custom_fields(journals)
175 182 field_ids = journals.map(&:details).flatten.select {|d| d.property == 'cf'}.map(&:prop_key).uniq
176 183 if field_ids.any?
177 184 fields_by_id = CustomField.where(:id => field_ids).inject({}) {|h, f| h[f.id] = f; h}
178 185 journals.each do |journal|
179 186 journal.details.each do |detail|
180 187 if detail.property == 'cf'
181 188 detail.instance_variable_set "@custom_field", fields_by_id[detail.prop_key.to_i]
182 189 end
183 190 end
184 191 end
185 192 end
186 193 journals
187 194 end
188 195
189 196 # Stores the values of the attributes and custom fields of the journalized object
190 197 def start
191 198 if journalized
192 199 @attributes_before_change = journalized.journalized_attribute_names.inject({}) do |h, attribute|
193 200 h[attribute] = journalized.send(attribute)
194 201 h
195 202 end
196 203 @custom_values_before_change = journalized.custom_field_values.inject({}) do |h, c|
197 204 h[c.custom_field_id] = c.value
198 205 h
199 206 end
200 207 end
201 208 self
202 209 end
203 210
204 211 # Adds a journal detail for an attachment that was added or removed
205 212 def journalize_attachment(attachment, added_or_removed)
206 213 key = (added_or_removed == :removed ? :old_value : :value)
207 214 details << JournalDetail.new(
208 215 :property => 'attachment',
209 216 :prop_key => attachment.id,
210 217 key => attachment.filename
211 218 )
212 219 end
213 220
214 221 # Adds a journal detail for an issue relation that was added or removed
215 222 def journalize_relation(relation, added_or_removed)
216 223 key = (added_or_removed == :removed ? :old_value : :value)
217 224 details << JournalDetail.new(
218 225 :property => 'relation',
219 226 :prop_key => relation.relation_type_for(journalized),
220 227 key => relation.other_issue(journalized).try(:id)
221 228 )
222 229 end
223 230
224 231 private
225 232
226 233 # Generates journal details for attribute and custom field changes
227 234 def journalize_changes
228 235 # attributes changes
229 236 if @attributes_before_change
230 237 attrs = (journalized.journalized_attribute_names + @attributes_before_change.keys).uniq
231 238 attrs.each do |attribute|
232 239 before = @attributes_before_change[attribute]
233 240 after = journalized.send(attribute)
234 241 next if before == after || (before.blank? && after.blank?)
235 242 add_attribute_detail(attribute, before, after)
236 243 end
237 244 end
238 245 # custom fields changes
239 246 if @custom_values_before_change
240 247 values_by_custom_field_id = {}
241 248 @custom_values_before_change.each do |custom_field_id, value|
242 249 values_by_custom_field_id[custom_field_id] = nil
243 250 end
244 251 journalized.custom_field_values.each do |c|
245 252 values_by_custom_field_id[c.custom_field_id] = c.value
246 253 end
247 254
248 255 values_by_custom_field_id.each do |custom_field_id, after|
249 256 before = @custom_values_before_change[custom_field_id]
250 257 next if before == after || (before.blank? && after.blank?)
251 258
252 259 if before.is_a?(Array) || after.is_a?(Array)
253 260 before = [before] unless before.is_a?(Array)
254 261 after = [after] unless after.is_a?(Array)
255 262
256 263 # values removed
257 264 (before - after).reject(&:blank?).each do |value|
258 265 add_custom_field_detail(custom_field_id, value, nil)
259 266 end
260 267 # values added
261 268 (after - before).reject(&:blank?).each do |value|
262 269 add_custom_field_detail(custom_field_id, nil, value)
263 270 end
264 271 else
265 272 add_custom_field_detail(custom_field_id, before, after)
266 273 end
267 274 end
268 275 end
269 276 start
270 277 end
271 278
272 279 # Adds a journal detail for an attribute change
273 280 def add_attribute_detail(attribute, old_value, value)
274 281 add_detail('attr', attribute, old_value, value)
275 282 end
276 283
277 284 # Adds a journal detail for a custom field value change
278 285 def add_custom_field_detail(custom_field_id, old_value, value)
279 286 add_detail('cf', custom_field_id, old_value, value)
280 287 end
281 288
282 289 # Adds a journal detail
283 290 def add_detail(property, prop_key, old_value, value)
284 291 details << JournalDetail.new(
285 292 :property => property,
286 293 :prop_key => prop_key,
287 294 :old_value => old_value,
288 295 :value => value
289 296 )
290 297 end
291 298
292 299 def split_private_notes
293 300 if private_notes?
294 301 if notes.present?
295 302 if details.any?
296 303 # Split the journal (notes/changes) so we don't have half-private journals
297 304 journal = Journal.new(:journalized => journalized, :user => user, :notes => nil, :private_notes => false)
298 305 journal.details = details
299 306 journal.save
300 307 self.details = []
301 308 self.created_on = journal.created_on
302 309 end
303 310 else
304 311 # Blank notes should not be private
305 312 self.private_notes = false
306 313 end
307 314 end
308 315 true
309 316 end
310 317
311 318 def send_notification
312 319 if notify? && (Setting.notified_events.include?('issue_updated') ||
313 320 (Setting.notified_events.include?('issue_note_added') && notes.present?) ||
314 321 (Setting.notified_events.include?('issue_status_updated') && new_status.present?) ||
315 322 (Setting.notified_events.include?('issue_assigned_to_updated') && detail_for_attribute('assigned_to_id').present?) ||
316 323 (Setting.notified_events.include?('issue_priority_updated') && new_value_for('priority_id').present?)
317 324 )
318 325 Mailer.deliver_issue_edit(self)
319 326 end
320 327 end
321 328 end
@@ -1,1095 +1,1096
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Project < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 include Redmine::NestedSet::ProjectNestedSet
21 21
22 22 # Project statuses
23 23 STATUS_ACTIVE = 1
24 24 STATUS_CLOSED = 5
25 25 STATUS_ARCHIVED = 9
26 26
27 27 # Maximum length for project identifiers
28 28 IDENTIFIER_MAX_LENGTH = 100
29 29
30 30 # Specific overridden Activities
31 31 has_many :time_entry_activities
32 32 has_many :memberships, :class_name => 'Member', :inverse_of => :project
33 33 # Memberships of active users only
34 34 has_many :members,
35 35 lambda { joins(:principal).where(:users => {:type => 'User', :status => Principal::STATUS_ACTIVE}) }
36 36 has_many :enabled_modules, :dependent => :delete_all
37 37 has_and_belongs_to_many :trackers, lambda {order(:position)}
38 38 has_many :issues, :dependent => :destroy
39 39 has_many :issue_changes, :through => :issues, :source => :journals
40 40 has_many :versions, lambda {order("#{Version.table_name}.effective_date DESC, #{Version.table_name}.name DESC")}, :dependent => :destroy
41 41 belongs_to :default_version, :class_name => 'Version'
42 42 has_many :time_entries, :dependent => :destroy
43 43 has_many :queries, :class_name => 'IssueQuery', :dependent => :delete_all
44 44 has_many :documents, :dependent => :destroy
45 45 has_many :news, lambda {includes(:author)}, :dependent => :destroy
46 46 has_many :issue_categories, lambda {order("#{IssueCategory.table_name}.name")}, :dependent => :delete_all
47 47 has_many :boards, lambda {order("position ASC")}, :dependent => :destroy
48 48 has_one :repository, lambda {where(["is_default = ?", true])}
49 49 has_many :repositories, :dependent => :destroy
50 50 has_many :changesets, :through => :repository
51 51 has_one :wiki, :dependent => :destroy
52 52 # Custom field for the project issues
53 53 has_and_belongs_to_many :issue_custom_fields,
54 54 lambda {order("#{CustomField.table_name}.position")},
55 55 :class_name => 'IssueCustomField',
56 56 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
57 57 :association_foreign_key => 'custom_field_id'
58 58
59 59 acts_as_attachable :view_permission => :view_files,
60 60 :edit_permission => :manage_files,
61 61 :delete_permission => :manage_files
62 62
63 63 acts_as_customizable
64 64 acts_as_searchable :columns => ['name', 'identifier', 'description'], :project_key => "#{Project.table_name}.id", :permission => nil
65 65 acts_as_event :title => Proc.new {|o| "#{l(:label_project)}: #{o.name}"},
66 66 :url => Proc.new {|o| {:controller => 'projects', :action => 'show', :id => o}},
67 67 :author => nil
68 68
69 69 attr_protected :status
70 70
71 71 validates_presence_of :name, :identifier
72 72 validates_uniqueness_of :identifier, :if => Proc.new {|p| p.identifier_changed?}
73 73 validates_length_of :name, :maximum => 255
74 74 validates_length_of :homepage, :maximum => 255
75 75 validates_length_of :identifier, :maximum => IDENTIFIER_MAX_LENGTH
76 76 # downcase letters, digits, dashes but not digits only
77 77 validates_format_of :identifier, :with => /\A(?!\d+$)[a-z0-9\-_]*\z/, :if => Proc.new { |p| p.identifier_changed? }
78 78 # reserved words
79 79 validates_exclusion_of :identifier, :in => %w( new )
80 80 validate :validate_parent
81 81
82 82 after_save :update_inherited_members, :if => Proc.new {|project| project.inherit_members_changed?}
83 83 after_save :remove_inherited_member_roles, :add_inherited_member_roles, :if => Proc.new {|project| project.parent_id_changed?}
84 84 after_update :update_versions_from_hierarchy_change, :if => Proc.new {|project| project.parent_id_changed?}
85 85 before_destroy :delete_all_members
86 86
87 87 scope :has_module, lambda {|mod|
88 88 where("#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s)
89 89 }
90 90 scope :active, lambda { where(:status => STATUS_ACTIVE) }
91 91 scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i}) }
92 92 scope :all_public, lambda { where(:is_public => true) }
93 93 scope :visible, lambda {|*args| where(Project.visible_condition(args.shift || User.current, *args)) }
94 94 scope :allowed_to, lambda {|*args|
95 95 user = User.current
96 96 permission = nil
97 97 if args.first.is_a?(Symbol)
98 98 permission = args.shift
99 99 else
100 100 user = args.shift
101 101 permission = args.shift
102 102 end
103 103 where(Project.allowed_to_condition(user, permission, *args))
104 104 }
105 105 scope :like, lambda {|arg|
106 106 if arg.blank?
107 107 where(nil)
108 108 else
109 109 pattern = "%#{arg.to_s.strip.downcase}%"
110 110 where("LOWER(identifier) LIKE :p OR LOWER(name) LIKE :p", :p => pattern)
111 111 end
112 112 }
113 113 scope :sorted, lambda {order(:lft)}
114 114 scope :having_trackers, lambda {
115 115 where("#{Project.table_name}.id IN (SELECT DISTINCT project_id FROM #{table_name_prefix}projects_trackers#{table_name_suffix})")
116 116 }
117 117
118 118 def initialize(attributes=nil, *args)
119 119 super
120 120
121 121 initialized = (attributes || {}).stringify_keys
122 122 if !initialized.key?('identifier') && Setting.sequential_project_identifiers?
123 123 self.identifier = Project.next_identifier
124 124 end
125 125 if !initialized.key?('is_public')
126 126 self.is_public = Setting.default_projects_public?
127 127 end
128 128 if !initialized.key?('enabled_module_names')
129 129 self.enabled_module_names = Setting.default_projects_modules
130 130 end
131 131 if !initialized.key?('trackers') && !initialized.key?('tracker_ids')
132 132 default = Setting.default_projects_tracker_ids
133 133 if default.is_a?(Array)
134 134 self.trackers = Tracker.where(:id => default.map(&:to_i)).sorted.to_a
135 135 else
136 136 self.trackers = Tracker.sorted.to_a
137 137 end
138 138 end
139 139 end
140 140
141 141 def identifier=(identifier)
142 142 super unless identifier_frozen?
143 143 end
144 144
145 145 def identifier_frozen?
146 146 errors[:identifier].blank? && !(new_record? || identifier.blank?)
147 147 end
148 148
149 149 # returns latest created projects
150 150 # non public projects will be returned only if user is a member of those
151 151 def self.latest(user=nil, count=5)
152 152 visible(user).limit(count).
153 153 order(:created_on => :desc).
154 154 where("#{table_name}.created_on >= ?", 30.days.ago).
155 155 to_a
156 156 end
157 157
158 158 # Returns true if the project is visible to +user+ or to the current user.
159 159 def visible?(user=User.current)
160 160 user.allowed_to?(:view_project, self)
161 161 end
162 162
163 163 # Returns a SQL conditions string used to find all projects visible by the specified user.
164 164 #
165 165 # Examples:
166 166 # Project.visible_condition(admin) => "projects.status = 1"
167 167 # Project.visible_condition(normal_user) => "((projects.status = 1) AND (projects.is_public = 1 OR projects.id IN (1,3,4)))"
168 168 # Project.visible_condition(anonymous) => "((projects.status = 1) AND (projects.is_public = 1))"
169 169 def self.visible_condition(user, options={})
170 170 allowed_to_condition(user, :view_project, options)
171 171 end
172 172
173 173 # Returns a SQL conditions string used to find all projects for which +user+ has the given +permission+
174 174 #
175 175 # Valid options:
176 # * :project => limit the condition to project
177 # * :with_subprojects => limit the condition to project and its subprojects
178 # * :member => limit the condition to the user projects
176 # * :skip_pre_condition => true don't check that the module is enabled (eg. when the condition is already set elsewhere in the query)
177 # * :project => project limit the condition to project
178 # * :with_subprojects => true limit the condition to project and its subprojects
179 # * :member => true limit the condition to the user projects
179 180 def self.allowed_to_condition(user, permission, options={})
180 181 perm = Redmine::AccessControl.permission(permission)
181 182 base_statement = (perm && perm.read? ? "#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED}" : "#{Project.table_name}.status = #{Project::STATUS_ACTIVE}")
182 if perm && perm.project_module
183 if !options[:skip_pre_condition] && perm && perm.project_module
183 184 # If the permission belongs to a project module, make sure the module is enabled
184 185 base_statement << " AND #{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name='#{perm.project_module}')"
185 186 end
186 187 if project = options[:project]
187 188 project_statement = project.project_condition(options[:with_subprojects])
188 189 base_statement = "(#{project_statement}) AND (#{base_statement})"
189 190 end
190 191
191 192 if user.admin?
192 193 base_statement
193 194 else
194 195 statement_by_role = {}
195 196 unless options[:member]
196 197 role = user.builtin_role
197 198 if role.allowed_to?(permission)
198 199 s = "#{Project.table_name}.is_public = #{connection.quoted_true}"
199 200 if user.id
200 201 group = role.anonymous? ? Group.anonymous : Group.non_member
201 202 principal_ids = [user.id, group.id].compact
202 203 s = "(#{s} AND #{Project.table_name}.id NOT IN (SELECT project_id FROM #{Member.table_name} WHERE user_id IN (#{principal_ids.join(',')})))"
203 204 end
204 205 statement_by_role[role] = s
205 206 end
206 207 end
207 208 user.project_ids_by_role.each do |role, project_ids|
208 209 if role.allowed_to?(permission) && project_ids.any?
209 210 statement_by_role[role] = "#{Project.table_name}.id IN (#{project_ids.join(',')})"
210 211 end
211 212 end
212 213 if statement_by_role.empty?
213 214 "1=0"
214 215 else
215 216 if block_given?
216 217 statement_by_role.each do |role, statement|
217 218 if s = yield(role, user)
218 219 statement_by_role[role] = "(#{statement} AND (#{s}))"
219 220 end
220 221 end
221 222 end
222 223 "((#{base_statement}) AND (#{statement_by_role.values.join(' OR ')}))"
223 224 end
224 225 end
225 226 end
226 227
227 228 def override_roles(role)
228 229 @override_members ||= memberships.
229 230 joins(:principal).
230 231 where(:users => {:type => ['GroupAnonymous', 'GroupNonMember']}).to_a
231 232
232 233 group_class = role.anonymous? ? GroupAnonymous : GroupNonMember
233 234 member = @override_members.detect {|m| m.principal.is_a? group_class}
234 235 member ? member.roles.to_a : [role]
235 236 end
236 237
237 238 def principals
238 239 @principals ||= Principal.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).distinct
239 240 end
240 241
241 242 def users
242 243 @users ||= User.active.joins(:members).where("#{Member.table_name}.project_id = ?", id).distinct
243 244 end
244 245
245 246 # Returns the Systemwide and project specific activities
246 247 def activities(include_inactive=false)
247 248 t = TimeEntryActivity.table_name
248 249 scope = TimeEntryActivity.where("#{t}.project_id IS NULL OR #{t}.project_id = ?", id)
249 250
250 251 overridden_activity_ids = self.time_entry_activities.pluck(:parent_id).compact
251 252 if overridden_activity_ids.any?
252 253 scope = scope.where("#{t}.id NOT IN (?)", overridden_activity_ids)
253 254 end
254 255 unless include_inactive
255 256 scope = scope.active
256 257 end
257 258 scope
258 259 end
259 260
260 261 # Will create a new Project specific Activity or update an existing one
261 262 #
262 263 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
263 264 # does not successfully save.
264 265 def update_or_create_time_entry_activity(id, activity_hash)
265 266 if activity_hash.respond_to?(:has_key?) && activity_hash.has_key?('parent_id')
266 267 self.create_time_entry_activity_if_needed(activity_hash)
267 268 else
268 269 activity = project.time_entry_activities.find_by_id(id.to_i)
269 270 activity.update_attributes(activity_hash) if activity
270 271 end
271 272 end
272 273
273 274 # Create a new TimeEntryActivity if it overrides a system TimeEntryActivity
274 275 #
275 276 # This will raise a ActiveRecord::Rollback if the TimeEntryActivity
276 277 # does not successfully save.
277 278 def create_time_entry_activity_if_needed(activity)
278 279 if activity['parent_id']
279 280 parent_activity = TimeEntryActivity.find(activity['parent_id'])
280 281 activity['name'] = parent_activity.name
281 282 activity['position'] = parent_activity.position
282 283 if Enumeration.overriding_change?(activity, parent_activity)
283 284 project_activity = self.time_entry_activities.create(activity)
284 285 if project_activity.new_record?
285 286 raise ActiveRecord::Rollback, "Overriding TimeEntryActivity was not successfully saved"
286 287 else
287 288 self.time_entries.
288 289 where(:activity_id => parent_activity.id).
289 290 update_all(:activity_id => project_activity.id)
290 291 end
291 292 end
292 293 end
293 294 end
294 295
295 296 # Returns a :conditions SQL string that can be used to find the issues associated with this project.
296 297 #
297 298 # Examples:
298 299 # project.project_condition(true) => "(projects.id = 1 OR (projects.lft > 1 AND projects.rgt < 10))"
299 300 # project.project_condition(false) => "projects.id = 1"
300 301 def project_condition(with_subprojects)
301 302 cond = "#{Project.table_name}.id = #{id}"
302 303 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
303 304 cond
304 305 end
305 306
306 307 def self.find(*args)
307 308 if args.first && args.first.is_a?(String) && !args.first.match(/^\d*$/)
308 309 project = find_by_identifier(*args)
309 310 raise ActiveRecord::RecordNotFound, "Couldn't find Project with identifier=#{args.first}" if project.nil?
310 311 project
311 312 else
312 313 super
313 314 end
314 315 end
315 316
316 317 def self.find_by_param(*args)
317 318 self.find(*args)
318 319 end
319 320
320 321 alias :base_reload :reload
321 322 def reload(*args)
322 323 @principals = nil
323 324 @users = nil
324 325 @shared_versions = nil
325 326 @rolled_up_versions = nil
326 327 @rolled_up_trackers = nil
327 328 @all_issue_custom_fields = nil
328 329 @all_time_entry_custom_fields = nil
329 330 @to_param = nil
330 331 @allowed_parents = nil
331 332 @allowed_permissions = nil
332 333 @actions_allowed = nil
333 334 @start_date = nil
334 335 @due_date = nil
335 336 @override_members = nil
336 337 @assignable_users = nil
337 338 base_reload(*args)
338 339 end
339 340
340 341 def to_param
341 342 if new_record?
342 343 nil
343 344 else
344 345 # id is used for projects with a numeric identifier (compatibility)
345 346 @to_param ||= (identifier.to_s =~ %r{^\d*$} ? id.to_s : identifier)
346 347 end
347 348 end
348 349
349 350 def active?
350 351 self.status == STATUS_ACTIVE
351 352 end
352 353
353 354 def closed?
354 355 self.status == STATUS_CLOSED
355 356 end
356 357
357 358 def archived?
358 359 self.status == STATUS_ARCHIVED
359 360 end
360 361
361 362 # Archives the project and its descendants
362 363 def archive
363 364 # Check that there is no issue of a non descendant project that is assigned
364 365 # to one of the project or descendant versions
365 366 version_ids = self_and_descendants.joins(:versions).pluck("#{Version.table_name}.id")
366 367
367 368 if version_ids.any? &&
368 369 Issue.
369 370 includes(:project).
370 371 where("#{Project.table_name}.lft < ? OR #{Project.table_name}.rgt > ?", lft, rgt).
371 372 where(:fixed_version_id => version_ids).
372 373 exists?
373 374 return false
374 375 end
375 376 Project.transaction do
376 377 archive!
377 378 end
378 379 true
379 380 end
380 381
381 382 # Unarchives the project
382 383 # All its ancestors must be active
383 384 def unarchive
384 385 return false if ancestors.detect {|a| a.archived?}
385 386 new_status = STATUS_ACTIVE
386 387 if parent
387 388 new_status = parent.status
388 389 end
389 390 update_attribute :status, new_status
390 391 end
391 392
392 393 def close
393 394 self_and_descendants.status(STATUS_ACTIVE).update_all :status => STATUS_CLOSED
394 395 end
395 396
396 397 def reopen
397 398 self_and_descendants.status(STATUS_CLOSED).update_all :status => STATUS_ACTIVE
398 399 end
399 400
400 401 # Returns an array of projects the project can be moved to
401 402 # by the current user
402 403 def allowed_parents(user=User.current)
403 404 return @allowed_parents if @allowed_parents
404 405 @allowed_parents = Project.allowed_to(user, :add_subprojects).to_a
405 406 @allowed_parents = @allowed_parents - self_and_descendants
406 407 if user.allowed_to?(:add_project, nil, :global => true) || (!new_record? && parent.nil?)
407 408 @allowed_parents << nil
408 409 end
409 410 unless parent.nil? || @allowed_parents.empty? || @allowed_parents.include?(parent)
410 411 @allowed_parents << parent
411 412 end
412 413 @allowed_parents
413 414 end
414 415
415 416 # Sets the parent of the project with authorization check
416 417 def set_allowed_parent!(p)
417 418 ActiveSupport::Deprecation.warn "Project#set_allowed_parent! is deprecated and will be removed in Redmine 4, use #safe_attributes= instead."
418 419 p = p.id if p.is_a?(Project)
419 420 send :safe_attributes, {:project_id => p}
420 421 save
421 422 end
422 423
423 424 # Sets the parent of the project and saves the project
424 425 # Argument can be either a Project, a String, a Fixnum or nil
425 426 def set_parent!(p)
426 427 if p.is_a?(Project)
427 428 self.parent = p
428 429 else
429 430 self.parent_id = p
430 431 end
431 432 save
432 433 end
433 434
434 435 # Returns a scope of the trackers used by the project and its active sub projects
435 436 def rolled_up_trackers(include_subprojects=true)
436 437 if include_subprojects
437 438 @rolled_up_trackers ||= rolled_up_trackers_base_scope.
438 439 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ?", lft, rgt)
439 440 else
440 441 rolled_up_trackers_base_scope.
441 442 where(:projects => {:id => id})
442 443 end
443 444 end
444 445
445 446 def rolled_up_trackers_base_scope
446 447 Tracker.
447 448 joins(projects: :enabled_modules).
448 449 where("#{Project.table_name}.status <> ?", STATUS_ARCHIVED).
449 450 where(:enabled_modules => {:name => 'issue_tracking'}).
450 451 distinct.
451 452 sorted
452 453 end
453 454
454 455 # Closes open and locked project versions that are completed
455 456 def close_completed_versions
456 457 Version.transaction do
457 458 versions.where(:status => %w(open locked)).each do |version|
458 459 if version.completed?
459 460 version.update_attribute(:status, 'closed')
460 461 end
461 462 end
462 463 end
463 464 end
464 465
465 466 # Returns a scope of the Versions on subprojects
466 467 def rolled_up_versions
467 468 @rolled_up_versions ||=
468 469 Version.
469 470 joins(:project).
470 471 where("#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ? AND #{Project.table_name}.status <> ?", lft, rgt, STATUS_ARCHIVED)
471 472 end
472 473
473 474 # Returns a scope of the Versions used by the project
474 475 def shared_versions
475 476 if new_record?
476 477 Version.
477 478 joins(:project).
478 479 preload(:project).
479 480 where("#{Project.table_name}.status <> ? AND #{Version.table_name}.sharing = 'system'", STATUS_ARCHIVED)
480 481 else
481 482 @shared_versions ||= begin
482 483 r = root? ? self : root
483 484 Version.
484 485 joins(:project).
485 486 preload(:project).
486 487 where("#{Project.table_name}.id = #{id}" +
487 488 " OR (#{Project.table_name}.status <> #{Project::STATUS_ARCHIVED} AND (" +
488 489 " #{Version.table_name}.sharing = 'system'" +
489 490 " OR (#{Project.table_name}.lft >= #{r.lft} AND #{Project.table_name}.rgt <= #{r.rgt} AND #{Version.table_name}.sharing = 'tree')" +
490 491 " OR (#{Project.table_name}.lft < #{lft} AND #{Project.table_name}.rgt > #{rgt} AND #{Version.table_name}.sharing IN ('hierarchy', 'descendants'))" +
491 492 " OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt} AND #{Version.table_name}.sharing = 'hierarchy')" +
492 493 "))")
493 494 end
494 495 end
495 496 end
496 497
497 498 # Returns a hash of project users grouped by role
498 499 def users_by_role
499 500 members.includes(:user, :roles).inject({}) do |h, m|
500 501 m.roles.each do |r|
501 502 h[r] ||= []
502 503 h[r] << m.user
503 504 end
504 505 h
505 506 end
506 507 end
507 508
508 509 # Adds user as a project member with the default role
509 510 # Used for when a non-admin user creates a project
510 511 def add_default_member(user)
511 512 role = self.class.default_member_role
512 513 member = Member.new(:project => self, :principal => user, :roles => [role])
513 514 self.members << member
514 515 member
515 516 end
516 517
517 518 # Default role that is given to non-admin users that
518 519 # create a project
519 520 def self.default_member_role
520 521 Role.givable.find_by_id(Setting.new_project_user_role_id.to_i) || Role.givable.first
521 522 end
522 523
523 524 # Deletes all project's members
524 525 def delete_all_members
525 526 me, mr = Member.table_name, MemberRole.table_name
526 527 self.class.connection.delete("DELETE FROM #{mr} WHERE #{mr}.member_id IN (SELECT #{me}.id FROM #{me} WHERE #{me}.project_id = #{id})")
527 528 Member.where(:project_id => id).delete_all
528 529 end
529 530
530 531 # Return a Principal scope of users/groups issues can be assigned to
531 532 def assignable_users(tracker=nil)
532 533 return @assignable_users[tracker] if @assignable_users && @assignable_users[tracker]
533 534
534 535 types = ['User']
535 536 types << 'Group' if Setting.issue_group_assignment?
536 537
537 538 scope = Principal.
538 539 active.
539 540 joins(:members => :roles).
540 541 where(:type => types, :members => {:project_id => id}, :roles => {:assignable => true}).
541 542 distinct.
542 543 sorted
543 544
544 545 if tracker
545 546 # Rejects users that cannot the view the tracker
546 547 roles = Role.where(:assignable => true).select {|role| role.permissions_tracker?(:view_issues, tracker)}
547 548 scope = scope.where(:roles => {:id => roles.map(&:id)})
548 549 end
549 550
550 551 @assignable_users ||= {}
551 552 @assignable_users[tracker] = scope
552 553 end
553 554
554 555 # Returns the mail addresses of users that should be always notified on project events
555 556 def recipients
556 557 notified_users.collect {|user| user.mail}
557 558 end
558 559
559 560 # Returns the users that should be notified on project events
560 561 def notified_users
561 562 # TODO: User part should be extracted to User#notify_about?
562 563 members.preload(:principal).select {|m| m.principal.present? && (m.mail_notification? || m.principal.mail_notification == 'all')}.collect {|m| m.principal}
563 564 end
564 565
565 566 # Returns a scope of all custom fields enabled for project issues
566 567 # (explicitly associated custom fields and custom fields enabled for all projects)
567 568 def all_issue_custom_fields
568 569 if new_record?
569 570 @all_issue_custom_fields ||= IssueCustomField.
570 571 sorted.
571 572 where("is_for_all = ? OR id IN (?)", true, issue_custom_field_ids)
572 573 else
573 574 @all_issue_custom_fields ||= IssueCustomField.
574 575 sorted.
575 576 where("is_for_all = ? OR id IN (SELECT DISTINCT cfp.custom_field_id" +
576 577 " FROM #{table_name_prefix}custom_fields_projects#{table_name_suffix} cfp" +
577 578 " WHERE cfp.project_id = ?)", true, id)
578 579 end
579 580 end
580 581
581 582 def project
582 583 self
583 584 end
584 585
585 586 def <=>(project)
586 587 name.casecmp(project.name)
587 588 end
588 589
589 590 def to_s
590 591 name
591 592 end
592 593
593 594 # Returns a short description of the projects (first lines)
594 595 def short_description(length = 255)
595 596 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
596 597 end
597 598
598 599 def css_classes
599 600 s = 'project'
600 601 s << ' root' if root?
601 602 s << ' child' if child?
602 603 s << (leaf? ? ' leaf' : ' parent')
603 604 unless active?
604 605 if archived?
605 606 s << ' archived'
606 607 else
607 608 s << ' closed'
608 609 end
609 610 end
610 611 s
611 612 end
612 613
613 614 # The earliest start date of a project, based on it's issues and versions
614 615 def start_date
615 616 @start_date ||= [
616 617 issues.minimum('start_date'),
617 618 shared_versions.minimum('effective_date'),
618 619 Issue.fixed_version(shared_versions).minimum('start_date')
619 620 ].compact.min
620 621 end
621 622
622 623 # The latest due date of an issue or version
623 624 def due_date
624 625 @due_date ||= [
625 626 issues.maximum('due_date'),
626 627 shared_versions.maximum('effective_date'),
627 628 Issue.fixed_version(shared_versions).maximum('due_date')
628 629 ].compact.max
629 630 end
630 631
631 632 def overdue?
632 633 active? && !due_date.nil? && (due_date < User.current.today)
633 634 end
634 635
635 636 # Returns the percent completed for this project, based on the
636 637 # progress on it's versions.
637 638 def completed_percent(options={:include_subprojects => false})
638 639 if options.delete(:include_subprojects)
639 640 total = self_and_descendants.collect(&:completed_percent).sum
640 641
641 642 total / self_and_descendants.count
642 643 else
643 644 if versions.count > 0
644 645 total = versions.collect(&:completed_percent).sum
645 646
646 647 total / versions.count
647 648 else
648 649 100
649 650 end
650 651 end
651 652 end
652 653
653 654 # Return true if this project allows to do the specified action.
654 655 # action can be:
655 656 # * a parameter-like Hash (eg. :controller => 'projects', :action => 'edit')
656 657 # * a permission Symbol (eg. :edit_project)
657 658 def allows_to?(action)
658 659 if archived?
659 660 # No action allowed on archived projects
660 661 return false
661 662 end
662 663 unless active? || Redmine::AccessControl.read_action?(action)
663 664 # No write action allowed on closed projects
664 665 return false
665 666 end
666 667 # No action allowed on disabled modules
667 668 if action.is_a? Hash
668 669 allowed_actions.include? "#{action[:controller]}/#{action[:action]}"
669 670 else
670 671 allowed_permissions.include? action
671 672 end
672 673 end
673 674
674 675 # Return the enabled module with the given name
675 676 # or nil if the module is not enabled for the project
676 677 def enabled_module(name)
677 678 name = name.to_s
678 679 enabled_modules.detect {|m| m.name == name}
679 680 end
680 681
681 682 # Return true if the module with the given name is enabled
682 683 def module_enabled?(name)
683 684 enabled_module(name).present?
684 685 end
685 686
686 687 def enabled_module_names=(module_names)
687 688 if module_names && module_names.is_a?(Array)
688 689 module_names = module_names.collect(&:to_s).reject(&:blank?)
689 690 self.enabled_modules = module_names.collect {|name| enabled_modules.detect {|mod| mod.name == name} || EnabledModule.new(:name => name)}
690 691 else
691 692 enabled_modules.clear
692 693 end
693 694 end
694 695
695 696 # Returns an array of the enabled modules names
696 697 def enabled_module_names
697 698 enabled_modules.collect(&:name)
698 699 end
699 700
700 701 # Enable a specific module
701 702 #
702 703 # Examples:
703 704 # project.enable_module!(:issue_tracking)
704 705 # project.enable_module!("issue_tracking")
705 706 def enable_module!(name)
706 707 enabled_modules << EnabledModule.new(:name => name.to_s) unless module_enabled?(name)
707 708 end
708 709
709 710 # Disable a module if it exists
710 711 #
711 712 # Examples:
712 713 # project.disable_module!(:issue_tracking)
713 714 # project.disable_module!("issue_tracking")
714 715 # project.disable_module!(project.enabled_modules.first)
715 716 def disable_module!(target)
716 717 target = enabled_modules.detect{|mod| target.to_s == mod.name} unless enabled_modules.include?(target)
717 718 target.destroy unless target.blank?
718 719 end
719 720
720 721 safe_attributes 'name',
721 722 'description',
722 723 'homepage',
723 724 'is_public',
724 725 'identifier',
725 726 'custom_field_values',
726 727 'custom_fields',
727 728 'tracker_ids',
728 729 'issue_custom_field_ids',
729 730 'parent_id',
730 731 'default_version_id'
731 732
732 733 safe_attributes 'enabled_module_names',
733 734 :if => lambda {|project, user|
734 735 if project.new_record?
735 736 if user.admin?
736 737 true
737 738 else
738 739 default_member_role.has_permission?(:select_project_modules)
739 740 end
740 741 else
741 742 user.allowed_to?(:select_project_modules, project)
742 743 end
743 744 }
744 745
745 746 safe_attributes 'inherit_members',
746 747 :if => lambda {|project, user| project.parent.nil? || project.parent.visible?(user)}
747 748
748 749 def safe_attributes=(attrs, user=User.current)
749 750 return unless attrs.is_a?(Hash)
750 751 attrs = attrs.deep_dup
751 752
752 753 @unallowed_parent_id = nil
753 754 if new_record? || attrs.key?('parent_id')
754 755 parent_id_param = attrs['parent_id'].to_s
755 756 if new_record? || parent_id_param != parent_id.to_s
756 757 p = parent_id_param.present? ? Project.find_by_id(parent_id_param) : nil
757 758 unless allowed_parents(user).include?(p)
758 759 attrs.delete('parent_id')
759 760 @unallowed_parent_id = true
760 761 end
761 762 end
762 763 end
763 764
764 765 super(attrs, user)
765 766 end
766 767
767 768 # Returns an auto-generated project identifier based on the last identifier used
768 769 def self.next_identifier
769 770 p = Project.order('id DESC').first
770 771 p.nil? ? nil : p.identifier.to_s.succ
771 772 end
772 773
773 774 # Copies and saves the Project instance based on the +project+.
774 775 # Duplicates the source project's:
775 776 # * Wiki
776 777 # * Versions
777 778 # * Categories
778 779 # * Issues
779 780 # * Members
780 781 # * Queries
781 782 #
782 783 # Accepts an +options+ argument to specify what to copy
783 784 #
784 785 # Examples:
785 786 # project.copy(1) # => copies everything
786 787 # project.copy(1, :only => 'members') # => copies members only
787 788 # project.copy(1, :only => ['members', 'versions']) # => copies members and versions
788 789 def copy(project, options={})
789 790 project = project.is_a?(Project) ? project : Project.find(project)
790 791
791 792 to_be_copied = %w(members wiki versions issue_categories issues queries boards)
792 793 to_be_copied = to_be_copied & Array.wrap(options[:only]) unless options[:only].nil?
793 794
794 795 Project.transaction do
795 796 if save
796 797 reload
797 798 to_be_copied.each do |name|
798 799 send "copy_#{name}", project
799 800 end
800 801 Redmine::Hook.call_hook(:model_project_copy_before_save, :source_project => project, :destination_project => self)
801 802 save
802 803 else
803 804 false
804 805 end
805 806 end
806 807 end
807 808
808 809 def member_principals
809 810 ActiveSupport::Deprecation.warn "Project#member_principals is deprecated and will be removed in Redmine 4.0. Use #memberships.active instead."
810 811 memberships.active
811 812 end
812 813
813 814 # Returns a new unsaved Project instance with attributes copied from +project+
814 815 def self.copy_from(project)
815 816 project = project.is_a?(Project) ? project : Project.find(project)
816 817 # clear unique attributes
817 818 attributes = project.attributes.dup.except('id', 'name', 'identifier', 'status', 'parent_id', 'lft', 'rgt')
818 819 copy = Project.new(attributes)
819 820 copy.enabled_module_names = project.enabled_module_names
820 821 copy.trackers = project.trackers
821 822 copy.custom_values = project.custom_values.collect {|v| v.clone}
822 823 copy.issue_custom_fields = project.issue_custom_fields
823 824 copy
824 825 end
825 826
826 827 # Yields the given block for each project with its level in the tree
827 828 def self.project_tree(projects, options={}, &block)
828 829 ancestors = []
829 830 if options[:init_level] && projects.first
830 831 ancestors = projects.first.ancestors.to_a
831 832 end
832 833 projects.sort_by(&:lft).each do |project|
833 834 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
834 835 ancestors.pop
835 836 end
836 837 yield project, ancestors.size
837 838 ancestors << project
838 839 end
839 840 end
840 841
841 842 private
842 843
843 844 def update_inherited_members
844 845 if parent
845 846 if inherit_members? && !inherit_members_was
846 847 remove_inherited_member_roles
847 848 add_inherited_member_roles
848 849 elsif !inherit_members? && inherit_members_was
849 850 remove_inherited_member_roles
850 851 end
851 852 end
852 853 end
853 854
854 855 def remove_inherited_member_roles
855 856 member_roles = MemberRole.where(:member_id => membership_ids).to_a
856 857 member_role_ids = member_roles.map(&:id)
857 858 member_roles.each do |member_role|
858 859 if member_role.inherited_from && !member_role_ids.include?(member_role.inherited_from)
859 860 member_role.destroy
860 861 end
861 862 end
862 863 end
863 864
864 865 def add_inherited_member_roles
865 866 if inherit_members? && parent
866 867 parent.memberships.each do |parent_member|
867 868 member = Member.find_or_new(self.id, parent_member.user_id)
868 869 parent_member.member_roles.each do |parent_member_role|
869 870 member.member_roles << MemberRole.new(:role => parent_member_role.role, :inherited_from => parent_member_role.id)
870 871 end
871 872 member.save!
872 873 end
873 874 memberships.reset
874 875 end
875 876 end
876 877
877 878 def update_versions_from_hierarchy_change
878 879 Issue.update_versions_from_hierarchy_change(self)
879 880 end
880 881
881 882 def validate_parent
882 883 if @unallowed_parent_id
883 884 errors.add(:parent_id, :invalid)
884 885 elsif parent_id_changed?
885 886 unless parent.nil? || (parent.active? && move_possible?(parent))
886 887 errors.add(:parent_id, :invalid)
887 888 end
888 889 end
889 890 end
890 891
891 892 # Copies wiki from +project+
892 893 def copy_wiki(project)
893 894 # Check that the source project has a wiki first
894 895 unless project.wiki.nil?
895 896 wiki = self.wiki || Wiki.new
896 897 wiki.attributes = project.wiki.attributes.dup.except("id", "project_id")
897 898 wiki_pages_map = {}
898 899 project.wiki.pages.each do |page|
899 900 # Skip pages without content
900 901 next if page.content.nil?
901 902 new_wiki_content = WikiContent.new(page.content.attributes.dup.except("id", "page_id", "updated_on"))
902 903 new_wiki_page = WikiPage.new(page.attributes.dup.except("id", "wiki_id", "created_on", "parent_id"))
903 904 new_wiki_page.content = new_wiki_content
904 905 wiki.pages << new_wiki_page
905 906 wiki_pages_map[page.id] = new_wiki_page
906 907 end
907 908
908 909 self.wiki = wiki
909 910 wiki.save
910 911 # Reproduce page hierarchy
911 912 project.wiki.pages.each do |page|
912 913 if page.parent_id && wiki_pages_map[page.id]
913 914 wiki_pages_map[page.id].parent = wiki_pages_map[page.parent_id]
914 915 wiki_pages_map[page.id].save
915 916 end
916 917 end
917 918 end
918 919 end
919 920
920 921 # Copies versions from +project+
921 922 def copy_versions(project)
922 923 project.versions.each do |version|
923 924 new_version = Version.new
924 925 new_version.attributes = version.attributes.dup.except("id", "project_id", "created_on", "updated_on")
925 926 self.versions << new_version
926 927 end
927 928 end
928 929
929 930 # Copies issue categories from +project+
930 931 def copy_issue_categories(project)
931 932 project.issue_categories.each do |issue_category|
932 933 new_issue_category = IssueCategory.new
933 934 new_issue_category.attributes = issue_category.attributes.dup.except("id", "project_id")
934 935 self.issue_categories << new_issue_category
935 936 end
936 937 end
937 938
938 939 # Copies issues from +project+
939 940 def copy_issues(project)
940 941 # Stores the source issue id as a key and the copied issues as the
941 942 # value. Used to map the two together for issue relations.
942 943 issues_map = {}
943 944
944 945 # Store status and reopen locked/closed versions
945 946 version_statuses = versions.reject(&:open?).map {|version| [version, version.status]}
946 947 version_statuses.each do |version, status|
947 948 version.update_attribute :status, 'open'
948 949 end
949 950
950 951 # Get issues sorted by root_id, lft so that parent issues
951 952 # get copied before their children
952 953 project.issues.reorder('root_id, lft').each do |issue|
953 954 new_issue = Issue.new
954 955 new_issue.copy_from(issue, :subtasks => false, :link => false)
955 956 new_issue.project = self
956 957 # Changing project resets the custom field values
957 958 # TODO: handle this in Issue#project=
958 959 new_issue.custom_field_values = issue.custom_field_values.inject({}) {|h,v| h[v.custom_field_id] = v.value; h}
959 960 # Reassign fixed_versions by name, since names are unique per project
960 961 if issue.fixed_version && issue.fixed_version.project == project
961 962 new_issue.fixed_version = self.versions.detect {|v| v.name == issue.fixed_version.name}
962 963 end
963 964 # Reassign version custom field values
964 965 new_issue.custom_field_values.each do |custom_value|
965 966 if custom_value.custom_field.field_format == 'version' && custom_value.value.present?
966 967 versions = Version.where(:id => custom_value.value).to_a
967 968 new_value = versions.map do |version|
968 969 if version.project == project
969 970 self.versions.detect {|v| v.name == version.name}.try(:id)
970 971 else
971 972 version.id
972 973 end
973 974 end
974 975 new_value.compact!
975 976 new_value = new_value.first unless custom_value.custom_field.multiple?
976 977 custom_value.value = new_value
977 978 end
978 979 end
979 980 # Reassign the category by name, since names are unique per project
980 981 if issue.category
981 982 new_issue.category = self.issue_categories.detect {|c| c.name == issue.category.name}
982 983 end
983 984 # Parent issue
984 985 if issue.parent_id
985 986 if copied_parent = issues_map[issue.parent_id]
986 987 new_issue.parent_issue_id = copied_parent.id
987 988 end
988 989 end
989 990
990 991 self.issues << new_issue
991 992 if new_issue.new_record?
992 993 logger.info "Project#copy_issues: issue ##{issue.id} could not be copied: #{new_issue.errors.full_messages}" if logger && logger.info?
993 994 else
994 995 issues_map[issue.id] = new_issue unless new_issue.new_record?
995 996 end
996 997 end
997 998
998 999 # Restore locked/closed version statuses
999 1000 version_statuses.each do |version, status|
1000 1001 version.update_attribute :status, status
1001 1002 end
1002 1003
1003 1004 # Relations after in case issues related each other
1004 1005 project.issues.each do |issue|
1005 1006 new_issue = issues_map[issue.id]
1006 1007 unless new_issue
1007 1008 # Issue was not copied
1008 1009 next
1009 1010 end
1010 1011
1011 1012 # Relations
1012 1013 issue.relations_from.each do |source_relation|
1013 1014 new_issue_relation = IssueRelation.new
1014 1015 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
1015 1016 new_issue_relation.issue_to = issues_map[source_relation.issue_to_id]
1016 1017 if new_issue_relation.issue_to.nil? && Setting.cross_project_issue_relations?
1017 1018 new_issue_relation.issue_to = source_relation.issue_to
1018 1019 end
1019 1020 new_issue.relations_from << new_issue_relation
1020 1021 end
1021 1022
1022 1023 issue.relations_to.each do |source_relation|
1023 1024 new_issue_relation = IssueRelation.new
1024 1025 new_issue_relation.attributes = source_relation.attributes.dup.except("id", "issue_from_id", "issue_to_id")
1025 1026 new_issue_relation.issue_from = issues_map[source_relation.issue_from_id]
1026 1027 if new_issue_relation.issue_from.nil? && Setting.cross_project_issue_relations?
1027 1028 new_issue_relation.issue_from = source_relation.issue_from
1028 1029 end
1029 1030 new_issue.relations_to << new_issue_relation
1030 1031 end
1031 1032 end
1032 1033 end
1033 1034
1034 1035 # Copies members from +project+
1035 1036 def copy_members(project)
1036 1037 # Copy users first, then groups to handle members with inherited and given roles
1037 1038 members_to_copy = []
1038 1039 members_to_copy += project.memberships.select {|m| m.principal.is_a?(User)}
1039 1040 members_to_copy += project.memberships.select {|m| !m.principal.is_a?(User)}
1040 1041
1041 1042 members_to_copy.each do |member|
1042 1043 new_member = Member.new
1043 1044 new_member.attributes = member.attributes.dup.except("id", "project_id", "created_on")
1044 1045 # only copy non inherited roles
1045 1046 # inherited roles will be added when copying the group membership
1046 1047 role_ids = member.member_roles.reject(&:inherited?).collect(&:role_id)
1047 1048 next if role_ids.empty?
1048 1049 new_member.role_ids = role_ids
1049 1050 new_member.project = self
1050 1051 self.members << new_member
1051 1052 end
1052 1053 end
1053 1054
1054 1055 # Copies queries from +project+
1055 1056 def copy_queries(project)
1056 1057 project.queries.each do |query|
1057 1058 new_query = IssueQuery.new
1058 1059 new_query.attributes = query.attributes.dup.except("id", "project_id", "sort_criteria", "user_id", "type")
1059 1060 new_query.sort_criteria = query.sort_criteria if query.sort_criteria
1060 1061 new_query.project = self
1061 1062 new_query.user_id = query.user_id
1062 1063 new_query.role_ids = query.role_ids if query.visibility == IssueQuery::VISIBILITY_ROLES
1063 1064 self.queries << new_query
1064 1065 end
1065 1066 end
1066 1067
1067 1068 # Copies boards from +project+
1068 1069 def copy_boards(project)
1069 1070 project.boards.each do |board|
1070 1071 new_board = Board.new
1071 1072 new_board.attributes = board.attributes.dup.except("id", "project_id", "topics_count", "messages_count", "last_message_id")
1072 1073 new_board.project = self
1073 1074 self.boards << new_board
1074 1075 end
1075 1076 end
1076 1077
1077 1078 def allowed_permissions
1078 1079 @allowed_permissions ||= begin
1079 1080 module_names = enabled_modules.loaded? ? enabled_modules.map(&:name) : enabled_modules.pluck(:name)
1080 1081 Redmine::AccessControl.modules_permissions(module_names).collect {|p| p.name}
1081 1082 end
1082 1083 end
1083 1084
1084 1085 def allowed_actions
1085 1086 @actions_allowed ||= allowed_permissions.inject([]) { |actions, permission| actions += Redmine::AccessControl.allowed_actions(permission) }.flatten
1086 1087 end
1087 1088
1088 1089 # Archives subprojects recursively
1089 1090 def archive!
1090 1091 children.each do |subproject|
1091 1092 subproject.send :archive!
1092 1093 end
1093 1094 update_attribute :status, STATUS_ARCHIVED
1094 1095 end
1095 1096 end
@@ -1,1315 +1,1315
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class QueryColumn
19 19 attr_accessor :name, :sortable, :groupable, :totalable, :default_order
20 20 include Redmine::I18n
21 21
22 22 def initialize(name, options={})
23 23 self.name = name
24 24 self.sortable = options[:sortable]
25 25 self.groupable = options[:groupable] || false
26 26 if groupable == true
27 27 self.groupable = name.to_s
28 28 end
29 29 self.totalable = options[:totalable] || false
30 30 self.default_order = options[:default_order]
31 31 @inline = options.key?(:inline) ? options[:inline] : true
32 32 @caption_key = options[:caption] || "field_#{name}".to_sym
33 33 @frozen = options[:frozen]
34 34 end
35 35
36 36 def caption
37 37 case @caption_key
38 38 when Symbol
39 39 l(@caption_key)
40 40 when Proc
41 41 @caption_key.call
42 42 else
43 43 @caption_key
44 44 end
45 45 end
46 46
47 47 # Returns true if the column is sortable, otherwise false
48 48 def sortable?
49 49 !@sortable.nil?
50 50 end
51 51
52 52 def sortable
53 53 @sortable.is_a?(Proc) ? @sortable.call : @sortable
54 54 end
55 55
56 56 def inline?
57 57 @inline
58 58 end
59 59
60 60 def frozen?
61 61 @frozen
62 62 end
63 63
64 64 def value(object)
65 65 object.send name
66 66 end
67 67
68 68 def value_object(object)
69 69 object.send name
70 70 end
71 71
72 72 def css_classes
73 73 name
74 74 end
75 75 end
76 76
77 77 class QueryAssociationColumn < QueryColumn
78 78
79 79 def initialize(association, attribute, options={})
80 80 @association = association
81 81 @attribute = attribute
82 82 name_with_assoc = "#{association}.#{attribute}".to_sym
83 83 super(name_with_assoc, options)
84 84 end
85 85
86 86 def value_object(object)
87 87 if assoc = object.send(@association)
88 88 assoc.send @attribute
89 89 end
90 90 end
91 91
92 92 def css_classes
93 93 @css_classes ||= "#{@association}-#{@attribute}"
94 94 end
95 95 end
96 96
97 97 class QueryCustomFieldColumn < QueryColumn
98 98
99 99 def initialize(custom_field, options={})
100 100 self.name = "cf_#{custom_field.id}".to_sym
101 101 self.sortable = custom_field.order_statement || false
102 102 self.groupable = custom_field.group_statement || false
103 103 self.totalable = options.key?(:totalable) ? !!options[:totalable] : custom_field.totalable?
104 104 @inline = true
105 105 @cf = custom_field
106 106 end
107 107
108 108 def caption
109 109 @cf.name
110 110 end
111 111
112 112 def custom_field
113 113 @cf
114 114 end
115 115
116 116 def value_object(object)
117 117 if custom_field.visible_by?(object.project, User.current)
118 118 cv = object.custom_values.select {|v| v.custom_field_id == @cf.id}
119 119 cv.size > 1 ? cv.sort {|a,b| a.value.to_s <=> b.value.to_s} : cv.first
120 120 else
121 121 nil
122 122 end
123 123 end
124 124
125 125 def value(object)
126 126 raw = value_object(object)
127 127 if raw.is_a?(Array)
128 128 raw.map {|r| @cf.cast_value(r.value)}
129 129 elsif raw
130 130 @cf.cast_value(raw.value)
131 131 else
132 132 nil
133 133 end
134 134 end
135 135
136 136 def css_classes
137 137 @css_classes ||= "#{name} #{@cf.field_format}"
138 138 end
139 139 end
140 140
141 141 class QueryAssociationCustomFieldColumn < QueryCustomFieldColumn
142 142
143 143 def initialize(association, custom_field, options={})
144 144 super(custom_field, options)
145 145 self.name = "#{association}.cf_#{custom_field.id}".to_sym
146 146 # TODO: support sorting/grouping by association custom field
147 147 self.sortable = false
148 148 self.groupable = false
149 149 @association = association
150 150 end
151 151
152 152 def value_object(object)
153 153 if assoc = object.send(@association)
154 154 super(assoc)
155 155 end
156 156 end
157 157
158 158 def css_classes
159 159 @css_classes ||= "#{@association}_cf_#{@cf.id} #{@cf.field_format}"
160 160 end
161 161 end
162 162
163 163 class QueryFilter
164 164 include Redmine::I18n
165 165
166 166 def initialize(field, options)
167 167 @field = field.to_s
168 168 @options = options
169 169 @options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
170 170 # Consider filters with a Proc for values as remote by default
171 171 @remote = options.key?(:remote) ? options[:remote] : options[:values].is_a?(Proc)
172 172 end
173 173
174 174 def [](arg)
175 175 if arg == :values
176 176 values
177 177 else
178 178 @options[arg]
179 179 end
180 180 end
181 181
182 182 def values
183 183 @values ||= begin
184 184 values = @options[:values]
185 185 if values.is_a?(Proc)
186 186 values = values.call
187 187 end
188 188 values
189 189 end
190 190 end
191 191
192 192 def remote
193 193 @remote
194 194 end
195 195 end
196 196
197 197 class Query < ActiveRecord::Base
198 198 class StatementInvalid < ::ActiveRecord::StatementInvalid
199 199 end
200 200
201 201 include Redmine::SubclassFactory
202 202
203 203 VISIBILITY_PRIVATE = 0
204 204 VISIBILITY_ROLES = 1
205 205 VISIBILITY_PUBLIC = 2
206 206
207 207 belongs_to :project
208 208 belongs_to :user
209 209 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}queries_roles#{table_name_suffix}", :foreign_key => "query_id"
210 210 serialize :filters
211 211 serialize :column_names
212 212 serialize :sort_criteria, Array
213 213 serialize :options, Hash
214 214
215 215 attr_protected :project_id, :user_id
216 216
217 217 validates_presence_of :name
218 218 validates_length_of :name, :maximum => 255
219 219 validates :visibility, :inclusion => { :in => [VISIBILITY_PUBLIC, VISIBILITY_ROLES, VISIBILITY_PRIVATE] }
220 220 validate :validate_query_filters
221 221 validate do |query|
222 222 errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) if query.visibility == VISIBILITY_ROLES && roles.blank?
223 223 end
224 224
225 225 after_save do |query|
226 226 if query.visibility_changed? && query.visibility != VISIBILITY_ROLES
227 227 query.roles.clear
228 228 end
229 229 end
230 230
231 231 class_attribute :operators
232 232 self.operators = {
233 233 "=" => :label_equals,
234 234 "!" => :label_not_equals,
235 235 "o" => :label_open_issues,
236 236 "c" => :label_closed_issues,
237 237 "!*" => :label_none,
238 238 "*" => :label_any,
239 239 ">=" => :label_greater_or_equal,
240 240 "<=" => :label_less_or_equal,
241 241 "><" => :label_between,
242 242 "<t+" => :label_in_less_than,
243 243 ">t+" => :label_in_more_than,
244 244 "><t+"=> :label_in_the_next_days,
245 245 "t+" => :label_in,
246 246 "t" => :label_today,
247 247 "ld" => :label_yesterday,
248 248 "w" => :label_this_week,
249 249 "lw" => :label_last_week,
250 250 "l2w" => [:label_last_n_weeks, {:count => 2}],
251 251 "m" => :label_this_month,
252 252 "lm" => :label_last_month,
253 253 "y" => :label_this_year,
254 254 ">t-" => :label_less_than_ago,
255 255 "<t-" => :label_more_than_ago,
256 256 "><t-"=> :label_in_the_past_days,
257 257 "t-" => :label_ago,
258 258 "~" => :label_contains,
259 259 "!~" => :label_not_contains,
260 260 "=p" => :label_any_issues_in_project,
261 261 "=!p" => :label_any_issues_not_in_project,
262 262 "!p" => :label_no_issues_in_project,
263 263 "*o" => :label_any_open_issues,
264 264 "!o" => :label_no_open_issues
265 265 }
266 266
267 267 class_attribute :operators_by_filter_type
268 268 self.operators_by_filter_type = {
269 269 :list => [ "=", "!" ],
270 270 :list_status => [ "o", "=", "!", "c", "*" ],
271 271 :list_optional => [ "=", "!", "!*", "*" ],
272 272 :list_subprojects => [ "*", "!*", "=", "!" ],
273 273 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
274 274 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "!*", "*" ],
275 275 :string => [ "=", "~", "!", "!~", "!*", "*" ],
276 276 :text => [ "~", "!~", "!*", "*" ],
277 277 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
278 278 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
279 279 :relation => ["=", "=p", "=!p", "!p", "*o", "!o", "!*", "*"],
280 280 :tree => ["=", "~", "!*", "*"]
281 281 }
282 282
283 283 class_attribute :available_columns
284 284 self.available_columns = []
285 285
286 286 class_attribute :queried_class
287 287
288 288 # Permission required to view the queries, set on subclasses.
289 289 class_attribute :view_permission
290 290
291 291 # Scope of queries that are global or on the given project
292 292 scope :global_or_on_project, lambda {|project|
293 293 where(:project_id => (project.nil? ? nil : [nil, project.id]))
294 294 }
295 295
296 296 scope :sorted, lambda {order(:name, :id)}
297 297
298 298 # Scope of visible queries, can be used from subclasses only.
299 299 # Unlike other visible scopes, a class methods is used as it
300 300 # let handle inheritance more nicely than scope DSL.
301 301 def self.visible(*args)
302 302 if self == ::Query
303 303 # Visibility depends on permissions for each subclass,
304 304 # raise an error if the scope is called from Query (eg. Query.visible)
305 305 raise Exception.new("Cannot call .visible scope from the base Query class, but from subclasses only.")
306 306 end
307 307
308 308 user = args.shift || User.current
309 309 base = Project.allowed_to_condition(user, view_permission, *args)
310 310 scope = joins("LEFT OUTER JOIN #{Project.table_name} ON #{table_name}.project_id = #{Project.table_name}.id").
311 311 where("#{table_name}.project_id IS NULL OR (#{base})")
312 312
313 313 if user.admin?
314 314 scope.where("#{table_name}.visibility <> ? OR #{table_name}.user_id = ?", VISIBILITY_PRIVATE, user.id)
315 315 elsif user.memberships.any?
316 316 scope.where("#{table_name}.visibility = ?" +
317 317 " OR (#{table_name}.visibility = ? AND #{table_name}.id IN (" +
318 318 "SELECT DISTINCT q.id FROM #{table_name} q" +
319 319 " INNER JOIN #{table_name_prefix}queries_roles#{table_name_suffix} qr on qr.query_id = q.id" +
320 320 " INNER JOIN #{MemberRole.table_name} mr ON mr.role_id = qr.role_id" +
321 321 " INNER JOIN #{Member.table_name} m ON m.id = mr.member_id AND m.user_id = ?" +
322 322 " WHERE q.project_id IS NULL OR q.project_id = m.project_id))" +
323 323 " OR #{table_name}.user_id = ?",
324 324 VISIBILITY_PUBLIC, VISIBILITY_ROLES, user.id, user.id)
325 325 elsif user.logged?
326 326 scope.where("#{table_name}.visibility = ? OR #{table_name}.user_id = ?", VISIBILITY_PUBLIC, user.id)
327 327 else
328 328 scope.where("#{table_name}.visibility = ?", VISIBILITY_PUBLIC)
329 329 end
330 330 end
331 331
332 332 # Returns true if the query is visible to +user+ or the current user.
333 333 def visible?(user=User.current)
334 334 return true if user.admin?
335 335 return false unless project.nil? || user.allowed_to?(self.class.view_permission, project)
336 336 case visibility
337 337 when VISIBILITY_PUBLIC
338 338 true
339 339 when VISIBILITY_ROLES
340 340 if project
341 341 (user.roles_for_project(project) & roles).any?
342 342 else
343 343 Member.where(:user_id => user.id).joins(:roles).where(:member_roles => {:role_id => roles.map(&:id)}).any?
344 344 end
345 345 else
346 346 user == self.user
347 347 end
348 348 end
349 349
350 350 def is_private?
351 351 visibility == VISIBILITY_PRIVATE
352 352 end
353 353
354 354 def is_public?
355 355 !is_private?
356 356 end
357 357
358 358 def queried_table_name
359 359 @queried_table_name ||= self.class.queried_class.table_name
360 360 end
361 361
362 362 def initialize(attributes=nil, *args)
363 363 super attributes
364 364 @is_for_all = project.nil?
365 365 end
366 366
367 367 # Builds the query from the given params
368 368 def build_from_params(params)
369 369 if params[:fields] || params[:f]
370 370 self.filters = {}
371 371 add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v])
372 372 else
373 373 available_filters.keys.each do |field|
374 374 add_short_filter(field, params[field]) if params[field]
375 375 end
376 376 end
377 377 self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
378 378 self.column_names = params[:c] || (params[:query] && params[:query][:column_names])
379 379 self.totalable_names = params[:t] || (params[:query] && params[:query][:totalable_names])
380 380 self
381 381 end
382 382
383 383 # Builds a new query from the given params and attributes
384 384 def self.build_from_params(params, attributes={})
385 385 new(attributes).build_from_params(params)
386 386 end
387 387
388 388 def validate_query_filters
389 389 filters.each_key do |field|
390 390 if values_for(field)
391 391 case type_for(field)
392 392 when :integer
393 393 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/\A[+-]?\d+(,[+-]?\d+)*\z/) }
394 394 when :float
395 395 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/\A[+-]?\d+(\.\d*)?\z/) }
396 396 when :date, :date_past
397 397 case operator_for(field)
398 398 when "=", ">=", "<=", "><"
399 399 add_filter_error(field, :invalid) if values_for(field).detect {|v|
400 400 v.present? && (!v.match(/\A\d{4}-\d{2}-\d{2}(T\d{2}((:)?\d{2}){0,2}(Z|\d{2}:?\d{2})?)?\z/) || parse_date(v).nil?)
401 401 }
402 402 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
403 403 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
404 404 end
405 405 end
406 406 end
407 407
408 408 add_filter_error(field, :blank) unless
409 409 # filter requires one or more values
410 410 (values_for(field) and !values_for(field).first.blank?) or
411 411 # filter doesn't require any value
412 412 ["o", "c", "!*", "*", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "*o", "!o"].include? operator_for(field)
413 413 end if filters
414 414 end
415 415
416 416 def add_filter_error(field, message)
417 417 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
418 418 errors.add(:base, m)
419 419 end
420 420
421 421 def editable_by?(user)
422 422 return false unless user
423 423 # Admin can edit them all and regular users can edit their private queries
424 424 return true if user.admin? || (is_private? && self.user_id == user.id)
425 425 # Members can not edit public queries that are for all project (only admin is allowed to)
426 426 is_public? && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
427 427 end
428 428
429 429 def trackers
430 430 @trackers ||= (project.nil? ? Tracker.all : project.rolled_up_trackers).visible.sorted
431 431 end
432 432
433 433 # Returns a hash of localized labels for all filter operators
434 434 def self.operators_labels
435 435 operators.inject({}) {|h, operator| h[operator.first] = l(*operator.last); h}
436 436 end
437 437
438 438 # Returns a representation of the available filters for JSON serialization
439 439 def available_filters_as_json
440 440 json = {}
441 441 available_filters.each do |field, filter|
442 442 options = {:type => filter[:type], :name => filter[:name]}
443 443 options[:remote] = true if filter.remote
444 444
445 445 if has_filter?(field) || !filter.remote
446 446 options[:values] = filter.values
447 447 if options[:values] && values_for(field)
448 448 missing = Array(values_for(field)).select(&:present?) - options[:values].map(&:last)
449 449 if missing.any? && respond_to?(method = "find_#{field}_filter_values")
450 450 options[:values] += send(method, missing)
451 451 end
452 452 end
453 453 end
454 454 json[field] = options.stringify_keys
455 455 end
456 456 json
457 457 end
458 458
459 459 def all_projects
460 460 @all_projects ||= Project.visible.to_a
461 461 end
462 462
463 463 def all_projects_values
464 464 return @all_projects_values if @all_projects_values
465 465
466 466 values = []
467 467 Project.project_tree(all_projects) do |p, level|
468 468 prefix = (level > 0 ? ('--' * level + ' ') : '')
469 469 values << ["#{prefix}#{p.name}", p.id.to_s]
470 470 end
471 471 @all_projects_values = values
472 472 end
473 473
474 474 def project_values
475 475 project_values = []
476 476 if User.current.logged? && User.current.memberships.any?
477 477 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
478 478 end
479 479 project_values += all_projects_values
480 480 project_values
481 481 end
482 482
483 483 def subproject_values
484 484 project.descendants.visible.collect{|s| [s.name, s.id.to_s] }
485 485 end
486 486
487 487 def principals
488 488 @principal ||= begin
489 489 principals = []
490 490 if project
491 491 principals += project.principals.visible
492 492 unless project.leaf?
493 493 principals += Principal.member_of(project.descendants.visible).visible
494 494 end
495 495 else
496 496 principals += Principal.member_of(all_projects).visible
497 497 end
498 498 principals.uniq!
499 499 principals.sort!
500 500 principals.reject! {|p| p.is_a?(GroupBuiltin)}
501 501 principals
502 502 end
503 503 end
504 504
505 505 def users
506 506 principals.select {|p| p.is_a?(User)}
507 507 end
508 508
509 509 def author_values
510 510 author_values = []
511 511 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
512 512 author_values += users.collect{|s| [s.name, s.id.to_s] }
513 513 author_values
514 514 end
515 515
516 516 def assigned_to_values
517 517 assigned_to_values = []
518 518 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
519 519 assigned_to_values += (Setting.issue_group_assignment? ? principals : users).collect{|s| [s.name, s.id.to_s] }
520 520 assigned_to_values
521 521 end
522 522
523 523 def fixed_version_values
524 524 versions = []
525 525 if project
526 526 versions = project.shared_versions.to_a
527 527 else
528 528 versions = Version.visible.where(:sharing => 'system').to_a
529 529 end
530 530 Version.sort_by_status(versions).collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s, l("version_status_#{s.status}")] }
531 531 end
532 532
533 533 # Adds available filters
534 534 def initialize_available_filters
535 535 # implemented by sub-classes
536 536 end
537 537 protected :initialize_available_filters
538 538
539 539 # Adds an available filter
540 540 def add_available_filter(field, options)
541 541 @available_filters ||= ActiveSupport::OrderedHash.new
542 542 @available_filters[field] = QueryFilter.new(field, options)
543 543 @available_filters
544 544 end
545 545
546 546 # Removes an available filter
547 547 def delete_available_filter(field)
548 548 if @available_filters
549 549 @available_filters.delete(field)
550 550 end
551 551 end
552 552
553 553 # Return a hash of available filters
554 554 def available_filters
555 555 unless @available_filters
556 556 initialize_available_filters
557 557 @available_filters ||= {}
558 558 end
559 559 @available_filters
560 560 end
561 561
562 562 def add_filter(field, operator, values=nil)
563 563 # values must be an array
564 564 return unless values.nil? || values.is_a?(Array)
565 565 # check if field is defined as an available filter
566 566 if available_filters.has_key? field
567 567 filter_options = available_filters[field]
568 568 filters[field] = {:operator => operator, :values => (values || [''])}
569 569 end
570 570 end
571 571
572 572 def add_short_filter(field, expression)
573 573 return unless expression && available_filters.has_key?(field)
574 574 field_type = available_filters[field][:type]
575 575 operators_by_filter_type[field_type].sort.reverse.detect do |operator|
576 576 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
577 577 values = $1
578 578 add_filter field, operator, values.present? ? values.split('|') : ['']
579 579 end || add_filter(field, '=', expression.to_s.split('|'))
580 580 end
581 581
582 582 # Add multiple filters using +add_filter+
583 583 def add_filters(fields, operators, values)
584 584 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
585 585 fields.each do |field|
586 586 add_filter(field, operators[field], values && values[field])
587 587 end
588 588 end
589 589 end
590 590
591 591 def has_filter?(field)
592 592 filters and filters[field]
593 593 end
594 594
595 595 def type_for(field)
596 596 available_filters[field][:type] if available_filters.has_key?(field)
597 597 end
598 598
599 599 def operator_for(field)
600 600 has_filter?(field) ? filters[field][:operator] : nil
601 601 end
602 602
603 603 def values_for(field)
604 604 has_filter?(field) ? filters[field][:values] : nil
605 605 end
606 606
607 607 def value_for(field, index=0)
608 608 (values_for(field) || [])[index]
609 609 end
610 610
611 611 def label_for(field)
612 612 label = available_filters[field][:name] if available_filters.has_key?(field)
613 613 label ||= queried_class.human_attribute_name(field, :default => field)
614 614 end
615 615
616 616 def self.add_available_column(column)
617 617 self.available_columns << (column) if column.is_a?(QueryColumn)
618 618 end
619 619
620 620 # Returns an array of columns that can be used to group the results
621 621 def groupable_columns
622 622 available_columns.select {|c| c.groupable}
623 623 end
624 624
625 625 # Returns a Hash of columns and the key for sorting
626 626 def sortable_columns
627 627 available_columns.inject({}) {|h, column|
628 628 h[column.name.to_s] = column.sortable
629 629 h
630 630 }
631 631 end
632 632
633 633 def columns
634 634 # preserve the column_names order
635 635 cols = (has_default_columns? ? default_columns_names : column_names).collect do |name|
636 636 available_columns.find { |col| col.name == name }
637 637 end.compact
638 638 available_columns.select(&:frozen?) | cols
639 639 end
640 640
641 641 def inline_columns
642 642 columns.select(&:inline?)
643 643 end
644 644
645 645 def block_columns
646 646 columns.reject(&:inline?)
647 647 end
648 648
649 649 def available_inline_columns
650 650 available_columns.select(&:inline?)
651 651 end
652 652
653 653 def available_block_columns
654 654 available_columns.reject(&:inline?)
655 655 end
656 656
657 657 def available_totalable_columns
658 658 available_columns.select(&:totalable)
659 659 end
660 660
661 661 def default_columns_names
662 662 []
663 663 end
664 664
665 665 def default_totalable_names
666 666 []
667 667 end
668 668
669 669 def column_names=(names)
670 670 if names
671 671 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
672 672 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
673 673 if names.delete(:all_inline)
674 674 names = available_inline_columns.map(&:name) | names
675 675 end
676 676 # Set column_names to nil if default columns
677 677 if names == default_columns_names
678 678 names = nil
679 679 end
680 680 end
681 681 write_attribute(:column_names, names)
682 682 end
683 683
684 684 def has_column?(column)
685 685 name = column.is_a?(QueryColumn) ? column.name : column
686 686 columns.detect {|c| c.name == name}
687 687 end
688 688
689 689 def has_custom_field_column?
690 690 columns.any? {|column| column.is_a? QueryCustomFieldColumn}
691 691 end
692 692
693 693 def has_default_columns?
694 694 column_names.nil? || column_names.empty?
695 695 end
696 696
697 697 def totalable_columns
698 698 names = totalable_names
699 699 available_totalable_columns.select {|column| names.include?(column.name)}
700 700 end
701 701
702 702 def totalable_names=(names)
703 703 if names
704 704 names = names.select(&:present?).map {|n| n.is_a?(Symbol) ? n : n.to_sym}
705 705 end
706 706 options[:totalable_names] = names
707 707 end
708 708
709 709 def totalable_names
710 710 options[:totalable_names] || default_totalable_names || []
711 711 end
712 712
713 713 def sort_criteria=(arg)
714 714 c = []
715 715 if arg.is_a?(Hash)
716 716 arg = arg.keys.sort.collect {|k| arg[k]}
717 717 end
718 718 if arg
719 719 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
720 720 end
721 721 write_attribute(:sort_criteria, c)
722 722 end
723 723
724 724 def sort_criteria
725 725 read_attribute(:sort_criteria) || []
726 726 end
727 727
728 728 def sort_criteria_key(arg)
729 729 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
730 730 end
731 731
732 732 def sort_criteria_order(arg)
733 733 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
734 734 end
735 735
736 736 def sort_criteria_order_for(key)
737 737 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
738 738 end
739 739
740 740 # Returns the SQL sort order that should be prepended for grouping
741 741 def group_by_sort_order
742 742 if column = group_by_column
743 743 order = (sort_criteria_order_for(column.name) || column.default_order || 'asc').try(:upcase)
744 744 Array(column.sortable).map {|s| "#{s} #{order}"}
745 745 end
746 746 end
747 747
748 748 # Returns true if the query is a grouped query
749 749 def grouped?
750 750 !group_by_column.nil?
751 751 end
752 752
753 753 def group_by_column
754 754 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
755 755 end
756 756
757 757 def group_by_statement
758 758 group_by_column.try(:groupable)
759 759 end
760 760
761 761 def project_statement
762 762 project_clauses = []
763 763 active_subprojects_ids = []
764 764
765 765 active_subprojects_ids = project.descendants.active.map(&:id) if project
766 766 if active_subprojects_ids.any?
767 767 if has_filter?("subproject_id")
768 768 case operator_for("subproject_id")
769 769 when '='
770 770 # include the selected subprojects
771 771 ids = [project.id] + values_for("subproject_id").map(&:to_i)
772 772 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
773 773 when '!'
774 774 # exclude the selected subprojects
775 775 ids = [project.id] + active_subprojects_ids - values_for("subproject_id").map(&:to_i)
776 776 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
777 777 when '!*'
778 778 # main project only
779 779 project_clauses << "#{Project.table_name}.id = %d" % project.id
780 780 else
781 781 # all subprojects
782 782 project_clauses << "#{Project.table_name}.lft >= #{project.lft} AND #{Project.table_name}.rgt <= #{project.rgt}"
783 783 end
784 784 elsif Setting.display_subprojects_issues?
785 785 project_clauses << "#{Project.table_name}.lft >= #{project.lft} AND #{Project.table_name}.rgt <= #{project.rgt}"
786 786 else
787 787 project_clauses << "#{Project.table_name}.id = %d" % project.id
788 788 end
789 789 elsif project
790 790 project_clauses << "#{Project.table_name}.id = %d" % project.id
791 791 end
792 792 project_clauses.any? ? project_clauses.join(' AND ') : nil
793 793 end
794 794
795 795 def statement
796 796 # filters clauses
797 797 filters_clauses = []
798 798 filters.each_key do |field|
799 799 next if field == "subproject_id"
800 800 v = values_for(field).clone
801 801 next unless v and !v.empty?
802 802 operator = operator_for(field)
803 803
804 804 # "me" value substitution
805 if %w(assigned_to_id author_id user_id watcher_id).include?(field)
805 if %w(assigned_to_id author_id user_id watcher_id updated_by last_updated_by).include?(field)
806 806 if v.delete("me")
807 807 if User.current.logged?
808 808 v.push(User.current.id.to_s)
809 809 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
810 810 else
811 811 v.push("0")
812 812 end
813 813 end
814 814 end
815 815
816 816 if field == 'project_id'
817 817 if v.delete('mine')
818 818 v += User.current.memberships.map(&:project_id).map(&:to_s)
819 819 end
820 820 end
821 821
822 822 if field =~ /^cf_(\d+)\.cf_(\d+)$/
823 823 filters_clauses << sql_for_chained_custom_field(field, operator, v, $1, $2)
824 824 elsif field =~ /cf_(\d+)$/
825 825 # custom field
826 826 filters_clauses << sql_for_custom_field(field, operator, v, $1)
827 827 elsif field =~ /^cf_(\d+)\.(.+)$/
828 828 filters_clauses << sql_for_custom_field_attribute(field, operator, v, $1, $2)
829 829 elsif respond_to?(method = "sql_for_#{field.gsub('.','_')}_field")
830 830 # specific statement
831 831 filters_clauses << send(method, field, operator, v)
832 832 else
833 833 # regular field
834 834 filters_clauses << '(' + sql_for_field(field, operator, v, queried_table_name, field) + ')'
835 835 end
836 836 end if filters and valid?
837 837
838 838 if (c = group_by_column) && c.is_a?(QueryCustomFieldColumn)
839 839 # Excludes results for which the grouped custom field is not visible
840 840 filters_clauses << c.custom_field.visibility_by_project_condition
841 841 end
842 842
843 843 filters_clauses << project_statement
844 844 filters_clauses.reject!(&:blank?)
845 845
846 846 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
847 847 end
848 848
849 849 # Returns the sum of values for the given column
850 850 def total_for(column)
851 851 total_with_scope(column, base_scope)
852 852 end
853 853
854 854 # Returns a hash of the sum of the given column for each group,
855 855 # or nil if the query is not grouped
856 856 def total_by_group_for(column)
857 857 grouped_query do |scope|
858 858 total_with_scope(column, scope)
859 859 end
860 860 end
861 861
862 862 def totals
863 863 totals = totalable_columns.map {|column| [column, total_for(column)]}
864 864 yield totals if block_given?
865 865 totals
866 866 end
867 867
868 868 def totals_by_group
869 869 totals = totalable_columns.map {|column| [column, total_by_group_for(column)]}
870 870 yield totals if block_given?
871 871 totals
872 872 end
873 873
874 874 private
875 875
876 876 def grouped_query(&block)
877 877 r = nil
878 878 if grouped?
879 879 begin
880 880 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
881 881 r = yield base_group_scope
882 882 rescue ActiveRecord::RecordNotFound
883 883 r = {nil => yield(base_scope)}
884 884 end
885 885 c = group_by_column
886 886 if c.is_a?(QueryCustomFieldColumn)
887 887 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
888 888 end
889 889 end
890 890 r
891 891 rescue ::ActiveRecord::StatementInvalid => e
892 892 raise StatementInvalid.new(e.message)
893 893 end
894 894
895 895 def total_with_scope(column, scope)
896 896 unless column.is_a?(QueryColumn)
897 897 column = column.to_sym
898 898 column = available_totalable_columns.detect {|c| c.name == column}
899 899 end
900 900 if column.is_a?(QueryCustomFieldColumn)
901 901 custom_field = column.custom_field
902 902 send "total_for_custom_field", custom_field, scope
903 903 else
904 904 send "total_for_#{column.name}", scope
905 905 end
906 906 rescue ::ActiveRecord::StatementInvalid => e
907 907 raise StatementInvalid.new(e.message)
908 908 end
909 909
910 910 def base_scope
911 911 raise "unimplemented"
912 912 end
913 913
914 914 def base_group_scope
915 915 base_scope.
916 916 joins(joins_for_order_statement(group_by_statement)).
917 917 group(group_by_statement)
918 918 end
919 919
920 920 def total_for_custom_field(custom_field, scope, &block)
921 921 total = custom_field.format.total_for_scope(custom_field, scope)
922 922 total = map_total(total) {|t| custom_field.format.cast_total_value(custom_field, t)}
923 923 total
924 924 end
925 925
926 926 def map_total(total, &block)
927 927 if total.is_a?(Hash)
928 928 total.keys.each {|k| total[k] = yield total[k]}
929 929 else
930 930 total = yield total
931 931 end
932 932 total
933 933 end
934 934
935 935 def sql_for_custom_field(field, operator, value, custom_field_id)
936 936 db_table = CustomValue.table_name
937 937 db_field = 'value'
938 938 filter = @available_filters[field]
939 939 return nil unless filter
940 940 if filter[:field].format.target_class && filter[:field].format.target_class <= User
941 941 if value.delete('me')
942 942 value.push User.current.id.to_s
943 943 end
944 944 end
945 945 not_in = nil
946 946 if operator == '!'
947 947 # Makes ! operator work for custom fields with multiple values
948 948 operator = '='
949 949 not_in = 'NOT'
950 950 end
951 951 customized_key = "id"
952 952 customized_class = queried_class
953 953 if field =~ /^(.+)\.cf_/
954 954 assoc = $1
955 955 customized_key = "#{assoc}_id"
956 956 customized_class = queried_class.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
957 957 raise "Unknown #{queried_class.name} association #{assoc}" unless customized_class
958 958 end
959 959 where = sql_for_field(field, operator, value, db_table, db_field, true)
960 960 if operator =~ /[<>]/
961 961 where = "(#{where}) AND #{db_table}.#{db_field} <> ''"
962 962 end
963 963 "#{queried_table_name}.#{customized_key} #{not_in} IN (" +
964 964 "SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name}" +
965 965 " LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='#{customized_class}' AND #{db_table}.customized_id=#{customized_class.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id}" +
966 966 " WHERE (#{where}) AND (#{filter[:field].visibility_by_project_condition}))"
967 967 end
968 968
969 969 def sql_for_chained_custom_field(field, operator, value, custom_field_id, chained_custom_field_id)
970 970 not_in = nil
971 971 if operator == '!'
972 972 # Makes ! operator work for custom fields with multiple values
973 973 operator = '='
974 974 not_in = 'NOT'
975 975 end
976 976
977 977 filter = available_filters[field]
978 978 target_class = filter[:through].format.target_class
979 979
980 980 "#{queried_table_name}.id #{not_in} IN (" +
981 981 "SELECT customized_id FROM #{CustomValue.table_name}" +
982 982 " WHERE customized_type='#{queried_class}' AND custom_field_id=#{custom_field_id}" +
983 983 " AND CAST(CASE value WHEN '' THEN '0' ELSE value END AS decimal(30,0)) IN (" +
984 984 " SELECT customized_id FROM #{CustomValue.table_name}" +
985 985 " WHERE customized_type='#{target_class}' AND custom_field_id=#{chained_custom_field_id}" +
986 986 " AND #{sql_for_field(field, operator, value, CustomValue.table_name, 'value')}))"
987 987
988 988 end
989 989
990 990 def sql_for_custom_field_attribute(field, operator, value, custom_field_id, attribute)
991 991 attribute = 'effective_date' if attribute == 'due_date'
992 992 not_in = nil
993 993 if operator == '!'
994 994 # Makes ! operator work for custom fields with multiple values
995 995 operator = '='
996 996 not_in = 'NOT'
997 997 end
998 998
999 999 filter = available_filters[field]
1000 1000 target_table_name = filter[:field].format.target_class.table_name
1001 1001
1002 1002 "#{queried_table_name}.id #{not_in} IN (" +
1003 1003 "SELECT customized_id FROM #{CustomValue.table_name}" +
1004 1004 " WHERE customized_type='#{queried_class}' AND custom_field_id=#{custom_field_id}" +
1005 1005 " AND CAST(CASE value WHEN '' THEN '0' ELSE value END AS decimal(30,0)) IN (" +
1006 1006 " SELECT id FROM #{target_table_name} WHERE #{sql_for_field(field, operator, value, filter[:field].format.target_class.table_name, attribute)}))"
1007 1007 end
1008 1008
1009 1009 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
1010 1010 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
1011 1011 sql = ''
1012 1012 case operator
1013 1013 when "="
1014 1014 if value.any?
1015 1015 case type_for(field)
1016 1016 when :date, :date_past
1017 1017 sql = date_clause(db_table, db_field, parse_date(value.first), parse_date(value.first), is_custom_filter)
1018 1018 when :integer
1019 1019 int_values = value.first.to_s.scan(/[+-]?\d+/).map(&:to_i).join(",")
1020 1020 if int_values.present?
1021 1021 if is_custom_filter
1022 1022 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) IN (#{int_values}))"
1023 1023 else
1024 1024 sql = "#{db_table}.#{db_field} IN (#{int_values})"
1025 1025 end
1026 1026 else
1027 1027 sql = "1=0"
1028 1028 end
1029 1029 when :float
1030 1030 if is_custom_filter
1031 1031 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5})"
1032 1032 else
1033 1033 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
1034 1034 end
1035 1035 else
1036 1036 sql = queried_class.send(:sanitize_sql_for_conditions, ["#{db_table}.#{db_field} IN (?)", value])
1037 1037 end
1038 1038 else
1039 1039 # IN an empty set
1040 1040 sql = "1=0"
1041 1041 end
1042 1042 when "!"
1043 1043 if value.any?
1044 1044 sql = queried_class.send(:sanitize_sql_for_conditions, ["(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (?))", value])
1045 1045 else
1046 1046 # NOT IN an empty set
1047 1047 sql = "1=1"
1048 1048 end
1049 1049 when "!*"
1050 1050 sql = "#{db_table}.#{db_field} IS NULL"
1051 1051 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
1052 1052 when "*"
1053 1053 sql = "#{db_table}.#{db_field} IS NOT NULL"
1054 1054 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
1055 1055 when ">="
1056 1056 if [:date, :date_past].include?(type_for(field))
1057 1057 sql = date_clause(db_table, db_field, parse_date(value.first), nil, is_custom_filter)
1058 1058 else
1059 1059 if is_custom_filter
1060 1060 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) >= #{value.first.to_f})"
1061 1061 else
1062 1062 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
1063 1063 end
1064 1064 end
1065 1065 when "<="
1066 1066 if [:date, :date_past].include?(type_for(field))
1067 1067 sql = date_clause(db_table, db_field, nil, parse_date(value.first), is_custom_filter)
1068 1068 else
1069 1069 if is_custom_filter
1070 1070 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) <= #{value.first.to_f})"
1071 1071 else
1072 1072 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
1073 1073 end
1074 1074 end
1075 1075 when "><"
1076 1076 if [:date, :date_past].include?(type_for(field))
1077 1077 sql = date_clause(db_table, db_field, parse_date(value[0]), parse_date(value[1]), is_custom_filter)
1078 1078 else
1079 1079 if is_custom_filter
1080 1080 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) BETWEEN #{value[0].to_f} AND #{value[1].to_f})"
1081 1081 else
1082 1082 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
1083 1083 end
1084 1084 end
1085 1085 when "o"
1086 1086 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{self.class.connection.quoted_false})" if field == "status_id"
1087 1087 when "c"
1088 1088 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{self.class.connection.quoted_true})" if field == "status_id"
1089 1089 when "><t-"
1090 1090 # between today - n days and today
1091 1091 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0, is_custom_filter)
1092 1092 when ">t-"
1093 1093 # >= today - n days
1094 1094 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil, is_custom_filter)
1095 1095 when "<t-"
1096 1096 # <= today - n days
1097 1097 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i, is_custom_filter)
1098 1098 when "t-"
1099 1099 # = n days in past
1100 1100 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i, is_custom_filter)
1101 1101 when "><t+"
1102 1102 # between today and today + n days
1103 1103 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i, is_custom_filter)
1104 1104 when ">t+"
1105 1105 # >= today + n days
1106 1106 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil, is_custom_filter)
1107 1107 when "<t+"
1108 1108 # <= today + n days
1109 1109 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i, is_custom_filter)
1110 1110 when "t+"
1111 1111 # = today + n days
1112 1112 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i, is_custom_filter)
1113 1113 when "t"
1114 1114 # = today
1115 1115 sql = relative_date_clause(db_table, db_field, 0, 0, is_custom_filter)
1116 1116 when "ld"
1117 1117 # = yesterday
1118 1118 sql = relative_date_clause(db_table, db_field, -1, -1, is_custom_filter)
1119 1119 when "w"
1120 1120 # = this week
1121 1121 first_day_of_week = l(:general_first_day_of_week).to_i
1122 1122 day_of_week = User.current.today.cwday
1123 1123 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
1124 1124 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6, is_custom_filter)
1125 1125 when "lw"
1126 1126 # = last week
1127 1127 first_day_of_week = l(:general_first_day_of_week).to_i
1128 1128 day_of_week = User.current.today.cwday
1129 1129 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
1130 1130 sql = relative_date_clause(db_table, db_field, - days_ago - 7, - days_ago - 1, is_custom_filter)
1131 1131 when "l2w"
1132 1132 # = last 2 weeks
1133 1133 first_day_of_week = l(:general_first_day_of_week).to_i
1134 1134 day_of_week = User.current.today.cwday
1135 1135 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
1136 1136 sql = relative_date_clause(db_table, db_field, - days_ago - 14, - days_ago - 1, is_custom_filter)
1137 1137 when "m"
1138 1138 # = this month
1139 1139 date = User.current.today
1140 1140 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month, is_custom_filter)
1141 1141 when "lm"
1142 1142 # = last month
1143 1143 date = User.current.today.prev_month
1144 1144 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month, is_custom_filter)
1145 1145 when "y"
1146 1146 # = this year
1147 1147 date = User.current.today
1148 1148 sql = date_clause(db_table, db_field, date.beginning_of_year, date.end_of_year, is_custom_filter)
1149 1149 when "~"
1150 1150 sql = sql_contains("#{db_table}.#{db_field}", value.first)
1151 1151 when "!~"
1152 1152 sql = sql_contains("#{db_table}.#{db_field}", value.first, false)
1153 1153 else
1154 1154 raise "Unknown query operator #{operator}"
1155 1155 end
1156 1156
1157 1157 return sql
1158 1158 end
1159 1159
1160 1160 # Returns a SQL LIKE statement with wildcards
1161 1161 def sql_contains(db_field, value, match=true)
1162 1162 queried_class.send :sanitize_sql_for_conditions,
1163 1163 [Redmine::Database.like(db_field, '?', :match => match), "%#{value}%"]
1164 1164 end
1165 1165
1166 1166 # Adds a filter for the given custom field
1167 1167 def add_custom_field_filter(field, assoc=nil)
1168 1168 options = field.query_filter_options(self)
1169 1169
1170 1170 filter_id = "cf_#{field.id}"
1171 1171 filter_name = field.name
1172 1172 if assoc.present?
1173 1173 filter_id = "#{assoc}.#{filter_id}"
1174 1174 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
1175 1175 end
1176 1176 add_available_filter filter_id, options.merge({
1177 1177 :name => filter_name,
1178 1178 :field => field
1179 1179 })
1180 1180 end
1181 1181
1182 1182 # Adds filters for custom fields associated to the custom field target class
1183 1183 # Eg. having a version custom field "Milestone" for issues and a date custom field "Release date"
1184 1184 # for versions, it will add an issue filter on Milestone'e Release date.
1185 1185 def add_chained_custom_field_filters(field)
1186 1186 klass = field.format.target_class
1187 1187 if klass
1188 1188 CustomField.where(:is_filter => true, :type => "#{klass.name}CustomField").each do |chained|
1189 1189 options = chained.query_filter_options(self)
1190 1190
1191 1191 filter_id = "cf_#{field.id}.cf_#{chained.id}"
1192 1192 filter_name = chained.name
1193 1193
1194 1194 add_available_filter filter_id, options.merge({
1195 1195 :name => l(:label_attribute_of_object, :name => chained.name, :object_name => field.name),
1196 1196 :field => chained,
1197 1197 :through => field
1198 1198 })
1199 1199 end
1200 1200 end
1201 1201 end
1202 1202
1203 1203 # Adds filters for the given custom fields scope
1204 1204 def add_custom_fields_filters(scope, assoc=nil)
1205 1205 scope.visible.where(:is_filter => true).sorted.each do |field|
1206 1206 add_custom_field_filter(field, assoc)
1207 1207 if assoc.nil?
1208 1208 add_chained_custom_field_filters(field)
1209 1209
1210 1210 if field.format.target_class && field.format.target_class == Version
1211 1211 add_available_filter "cf_#{field.id}.due_date",
1212 1212 :type => :date,
1213 1213 :field => field,
1214 1214 :name => l(:label_attribute_of_object, :name => l(:field_effective_date), :object_name => field.name)
1215 1215
1216 1216 add_available_filter "cf_#{field.id}.status",
1217 1217 :type => :list,
1218 1218 :field => field,
1219 1219 :name => l(:label_attribute_of_object, :name => l(:field_status), :object_name => field.name),
1220 1220 :values => Version::VERSION_STATUSES.map{|s| [l("version_status_#{s}"), s] }
1221 1221 end
1222 1222 end
1223 1223 end
1224 1224 end
1225 1225
1226 1226 # Adds filters for the given associations custom fields
1227 1227 def add_associations_custom_fields_filters(*associations)
1228 1228 fields_by_class = CustomField.visible.where(:is_filter => true).group_by(&:class)
1229 1229 associations.each do |assoc|
1230 1230 association_klass = queried_class.reflect_on_association(assoc).klass
1231 1231 fields_by_class.each do |field_class, fields|
1232 1232 if field_class.customized_class <= association_klass
1233 1233 fields.sort.each do |field|
1234 1234 add_custom_field_filter(field, assoc)
1235 1235 end
1236 1236 end
1237 1237 end
1238 1238 end
1239 1239 end
1240 1240
1241 1241 def quoted_time(time, is_custom_filter)
1242 1242 if is_custom_filter
1243 1243 # Custom field values are stored as strings in the DB
1244 1244 # using this format that does not depend on DB date representation
1245 1245 time.strftime("%Y-%m-%d %H:%M:%S")
1246 1246 else
1247 1247 self.class.connection.quoted_date(time)
1248 1248 end
1249 1249 end
1250 1250
1251 1251 def date_for_user_time_zone(y, m, d)
1252 1252 if tz = User.current.time_zone
1253 1253 tz.local y, m, d
1254 1254 else
1255 1255 Time.local y, m, d
1256 1256 end
1257 1257 end
1258 1258
1259 1259 # Returns a SQL clause for a date or datetime field.
1260 1260 def date_clause(table, field, from, to, is_custom_filter)
1261 1261 s = []
1262 1262 if from
1263 1263 if from.is_a?(Date)
1264 1264 from = date_for_user_time_zone(from.year, from.month, from.day).yesterday.end_of_day
1265 1265 else
1266 1266 from = from - 1 # second
1267 1267 end
1268 1268 if self.class.default_timezone == :utc
1269 1269 from = from.utc
1270 1270 end
1271 1271 s << ("#{table}.#{field} > '%s'" % [quoted_time(from, is_custom_filter)])
1272 1272 end
1273 1273 if to
1274 1274 if to.is_a?(Date)
1275 1275 to = date_for_user_time_zone(to.year, to.month, to.day).end_of_day
1276 1276 end
1277 1277 if self.class.default_timezone == :utc
1278 1278 to = to.utc
1279 1279 end
1280 1280 s << ("#{table}.#{field} <= '%s'" % [quoted_time(to, is_custom_filter)])
1281 1281 end
1282 1282 s.join(' AND ')
1283 1283 end
1284 1284
1285 1285 # Returns a SQL clause for a date or datetime field using relative dates.
1286 1286 def relative_date_clause(table, field, days_from, days_to, is_custom_filter)
1287 1287 date_clause(table, field, (days_from ? User.current.today + days_from : nil), (days_to ? User.current.today + days_to : nil), is_custom_filter)
1288 1288 end
1289 1289
1290 1290 # Returns a Date or Time from the given filter value
1291 1291 def parse_date(arg)
1292 1292 if arg.to_s =~ /\A\d{4}-\d{2}-\d{2}T/
1293 1293 Time.parse(arg) rescue nil
1294 1294 else
1295 1295 Date.parse(arg) rescue nil
1296 1296 end
1297 1297 end
1298 1298
1299 1299 # Additional joins required for the given sort options
1300 1300 def joins_for_order_statement(order_options)
1301 1301 joins = []
1302 1302
1303 1303 if order_options
1304 1304 order_options.scan(/cf_\d+/).uniq.each do |name|
1305 1305 column = available_columns.detect {|c| c.name.to_s == name}
1306 1306 join = column && column.custom_field.join_for_order_statement
1307 1307 if join
1308 1308 joins << join
1309 1309 end
1310 1310 end
1311 1311 end
1312 1312
1313 1313 joins.any? ? joins.join(' ') : nil
1314 1314 end
1315 1315 end
@@ -1,1209 +1,1211
1 1 en:
2 2 # Text direction: Left-to-Right (ltr) or Right-to-Left (rtl)
3 3 direction: ltr
4 4 date:
5 5 formats:
6 6 # Use the strftime parameters for formats.
7 7 # When no format has been given, it uses default.
8 8 # You can provide other formats here if you like!
9 9 default: "%m/%d/%Y"
10 10 short: "%b %d"
11 11 long: "%B %d, %Y"
12 12
13 13 day_names: [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday]
14 14 abbr_day_names: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
15 15
16 16 # Don't forget the nil at the beginning; there's no such thing as a 0th month
17 17 month_names: [~, January, February, March, April, May, June, July, August, September, October, November, December]
18 18 abbr_month_names: [~, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
19 19 # Used in date_select and datime_select.
20 20 order:
21 21 - :year
22 22 - :month
23 23 - :day
24 24
25 25 time:
26 26 formats:
27 27 default: "%m/%d/%Y %I:%M %p"
28 28 time: "%I:%M %p"
29 29 short: "%d %b %H:%M"
30 30 long: "%B %d, %Y %H:%M"
31 31 am: "am"
32 32 pm: "pm"
33 33
34 34 datetime:
35 35 distance_in_words:
36 36 half_a_minute: "half a minute"
37 37 less_than_x_seconds:
38 38 one: "less than 1 second"
39 39 other: "less than %{count} seconds"
40 40 x_seconds:
41 41 one: "1 second"
42 42 other: "%{count} seconds"
43 43 less_than_x_minutes:
44 44 one: "less than a minute"
45 45 other: "less than %{count} minutes"
46 46 x_minutes:
47 47 one: "1 minute"
48 48 other: "%{count} minutes"
49 49 about_x_hours:
50 50 one: "about 1 hour"
51 51 other: "about %{count} hours"
52 52 x_hours:
53 53 one: "1 hour"
54 54 other: "%{count} hours"
55 55 x_days:
56 56 one: "1 day"
57 57 other: "%{count} days"
58 58 about_x_months:
59 59 one: "about 1 month"
60 60 other: "about %{count} months"
61 61 x_months:
62 62 one: "1 month"
63 63 other: "%{count} months"
64 64 about_x_years:
65 65 one: "about 1 year"
66 66 other: "about %{count} years"
67 67 over_x_years:
68 68 one: "over 1 year"
69 69 other: "over %{count} years"
70 70 almost_x_years:
71 71 one: "almost 1 year"
72 72 other: "almost %{count} years"
73 73
74 74 number:
75 75 format:
76 76 separator: "."
77 77 delimiter: ""
78 78 precision: 3
79 79
80 80 human:
81 81 format:
82 82 delimiter: ""
83 83 precision: 3
84 84 storage_units:
85 85 format: "%n %u"
86 86 units:
87 87 byte:
88 88 one: "Byte"
89 89 other: "Bytes"
90 90 kb: "KB"
91 91 mb: "MB"
92 92 gb: "GB"
93 93 tb: "TB"
94 94
95 95 # Used in array.to_sentence.
96 96 support:
97 97 array:
98 98 sentence_connector: "and"
99 99 skip_last_comma: false
100 100
101 101 activerecord:
102 102 errors:
103 103 template:
104 104 header:
105 105 one: "1 error prohibited this %{model} from being saved"
106 106 other: "%{count} errors prohibited this %{model} from being saved"
107 107 messages:
108 108 inclusion: "is not included in the list"
109 109 exclusion: "is reserved"
110 110 invalid: "is invalid"
111 111 confirmation: "doesn't match confirmation"
112 112 accepted: "must be accepted"
113 113 empty: "cannot be empty"
114 114 blank: "cannot be blank"
115 115 too_long: "is too long (maximum is %{count} characters)"
116 116 too_short: "is too short (minimum is %{count} characters)"
117 117 wrong_length: "is the wrong length (should be %{count} characters)"
118 118 taken: "has already been taken"
119 119 not_a_number: "is not a number"
120 120 not_a_date: "is not a valid date"
121 121 greater_than: "must be greater than %{count}"
122 122 greater_than_or_equal_to: "must be greater than or equal to %{count}"
123 123 equal_to: "must be equal to %{count}"
124 124 less_than: "must be less than %{count}"
125 125 less_than_or_equal_to: "must be less than or equal to %{count}"
126 126 odd: "must be odd"
127 127 even: "must be even"
128 128 greater_than_start_date: "must be greater than start date"
129 129 not_same_project: "doesn't belong to the same project"
130 130 circular_dependency: "This relation would create a circular dependency"
131 131 cant_link_an_issue_with_a_descendant: "An issue cannot be linked to one of its subtasks"
132 132 earlier_than_minimum_start_date: "cannot be earlier than %{date} because of preceding issues"
133 133 not_a_regexp: "is not a valid regular expression"
134 134 open_issue_with_closed_parent: "An open issue cannot be attached to a closed parent task"
135 135
136 136 actionview_instancetag_blank_option: Please select
137 137
138 138 general_text_No: 'No'
139 139 general_text_Yes: 'Yes'
140 140 general_text_no: 'no'
141 141 general_text_yes: 'yes'
142 142 general_lang_name: 'English'
143 143 general_csv_separator: ','
144 144 general_csv_decimal_separator: '.'
145 145 general_csv_encoding: ISO-8859-1
146 146 general_pdf_fontname: freesans
147 147 general_pdf_monospaced_fontname: freemono
148 148 general_first_day_of_week: '7'
149 149
150 150 notice_account_updated: Account was successfully updated.
151 151 notice_account_invalid_credentials: Invalid user or password
152 152 notice_account_password_updated: Password was successfully updated.
153 153 notice_account_wrong_password: Wrong password
154 154 notice_account_register_done: Account was successfully created. An email containing the instructions to activate your account was sent to %{email}.
155 155 notice_account_unknown_email: Unknown user.
156 156 notice_account_not_activated_yet: You haven't activated your account yet. If you want to receive a new activation email, please <a href="%{url}">click this link</a>.
157 157 notice_account_locked: Your account is locked.
158 158 notice_can_t_change_password: This account uses an external authentication source. Impossible to change the password.
159 159 notice_account_lost_email_sent: An email with instructions to choose a new password has been sent to you.
160 160 notice_account_activated: Your account has been activated. You can now log in.
161 161 notice_successful_create: Successful creation.
162 162 notice_successful_update: Successful update.
163 163 notice_successful_delete: Successful deletion.
164 164 notice_successful_connection: Successful connection.
165 165 notice_file_not_found: The page you were trying to access doesn't exist or has been removed.
166 166 notice_locking_conflict: Data has been updated by another user.
167 167 notice_not_authorized: You are not authorized to access this page.
168 168 notice_not_authorized_archived_project: The project you're trying to access has been archived.
169 169 notice_email_sent: "An email was sent to %{value}"
170 170 notice_email_error: "An error occurred while sending mail (%{value})"
171 171 notice_feeds_access_key_reseted: Your Atom access key was reset.
172 172 notice_api_access_key_reseted: Your API access key was reset.
173 173 notice_failed_to_save_issues: "Failed to save %{count} issue(s) on %{total} selected: %{ids}."
174 174 notice_failed_to_save_time_entries: "Failed to save %{count} time entrie(s) on %{total} selected: %{ids}."
175 175 notice_failed_to_save_members: "Failed to save member(s): %{errors}."
176 176 notice_no_issue_selected: "No issue is selected! Please, check the issues you want to edit."
177 177 notice_account_pending: "Your account was created and is now pending administrator approval."
178 178 notice_default_data_loaded: Default configuration successfully loaded.
179 179 notice_unable_delete_version: Unable to delete version.
180 180 notice_unable_delete_time_entry: Unable to delete time log entry.
181 181 notice_issue_done_ratios_updated: Issue done ratios updated.
182 182 notice_gantt_chart_truncated: "The chart was truncated because it exceeds the maximum number of items that can be displayed (%{max})"
183 183 notice_issue_successful_create: "Issue %{id} created."
184 184 notice_issue_update_conflict: "The issue has been updated by an other user while you were editing it."
185 185 notice_account_deleted: "Your account has been permanently deleted."
186 186 notice_user_successful_create: "User %{id} created."
187 187 notice_new_password_must_be_different: The new password must be different from the current password
188 188 notice_import_finished: "%{count} items have been imported"
189 189 notice_import_finished_with_errors: "%{count} out of %{total} items could not be imported"
190 190
191 191 error_can_t_load_default_data: "Default configuration could not be loaded: %{value}"
192 192 error_scm_not_found: "The entry or revision was not found in the repository."
193 193 error_scm_command_failed: "An error occurred when trying to access the repository: %{value}"
194 194 error_scm_annotate: "The entry does not exist or cannot be annotated."
195 195 error_scm_annotate_big_text_file: "The entry cannot be annotated, as it exceeds the maximum text file size."
196 196 error_issue_not_found_in_project: 'The issue was not found or does not belong to this project'
197 197 error_no_tracker_in_project: 'No tracker is associated to this project. Please check the Project settings.'
198 198 error_no_default_issue_status: 'No default issue status is defined. Please check your configuration (Go to "Administration -> Issue statuses").'
199 199 error_can_not_delete_custom_field: Unable to delete custom field
200 200 error_can_not_delete_tracker: "This tracker contains issues and cannot be deleted."
201 201 error_can_not_remove_role: "This role is in use and cannot be deleted."
202 202 error_can_not_reopen_issue_on_closed_version: 'An issue assigned to a closed version cannot be reopened'
203 203 error_can_not_archive_project: This project cannot be archived
204 204 error_issue_done_ratios_not_updated: "Issue done ratios not updated."
205 205 error_workflow_copy_source: 'Please select a source tracker or role'
206 206 error_workflow_copy_target: 'Please select target tracker(s) and role(s)'
207 207 error_unable_delete_issue_status: 'Unable to delete issue status'
208 208 error_unable_to_connect: "Unable to connect (%{value})"
209 209 error_attachment_too_big: "This file cannot be uploaded because it exceeds the maximum allowed file size (%{max_size})"
210 210 error_session_expired: "Your session has expired. Please login again."
211 211 warning_attachments_not_saved: "%{count} file(s) could not be saved."
212 212 error_password_expired: "Your password has expired or the administrator requires you to change it."
213 213 error_invalid_file_encoding: "The file is not a valid %{encoding} encoded file"
214 214 error_invalid_csv_file_or_settings: "The file is not a CSV file or does not match the settings below"
215 215 error_can_not_read_import_file: "An error occurred while reading the file to import"
216 216 error_attachment_extension_not_allowed: "Attachment extension %{extension} is not allowed"
217 217 error_ldap_bind_credentials: "Invalid LDAP Account/Password"
218 218 error_no_tracker_allowed_for_new_issue_in_project: "The project doesn't have any trackers for which you can create an issue"
219 219 error_no_projects_with_tracker_allowed_for_new_issue: "There are no projects with trackers for which you can create an issue"
220 220 error_move_of_child_not_possible: "Subtask %{child} could not be moved to the new project: %{errors}"
221 221 error_cannot_reassign_time_entries_to_an_issue_about_to_be_deleted: "Spent time cannot be reassigned to an issue that is about to be deleted"
222 222 warning_fields_cleared_on_bulk_edit: "Changes will result in the automatic deletion of values from one or more fields on the selected objects"
223 223
224 224 mail_subject_lost_password: "Your %{value} password"
225 225 mail_body_lost_password: 'To change your password, click on the following link:'
226 226 mail_subject_register: "Your %{value} account activation"
227 227 mail_body_register: 'To activate your account, click on the following link:'
228 228 mail_body_account_information_external: "You can use your %{value} account to log in."
229 229 mail_body_account_information: Your account information
230 230 mail_subject_account_activation_request: "%{value} account activation request"
231 231 mail_body_account_activation_request: "A new user (%{value}) has registered. The account is pending your approval:"
232 232 mail_subject_reminder: "%{count} issue(s) due in the next %{days} days"
233 233 mail_body_reminder: "%{count} issue(s) that are assigned to you are due in the next %{days} days:"
234 234 mail_subject_wiki_content_added: "'%{id}' wiki page has been added"
235 235 mail_body_wiki_content_added: "The '%{id}' wiki page has been added by %{author}."
236 236 mail_subject_wiki_content_updated: "'%{id}' wiki page has been updated"
237 237 mail_body_wiki_content_updated: "The '%{id}' wiki page has been updated by %{author}."
238 238 mail_subject_security_notification: "Security notification"
239 239 mail_body_security_notification_change: "%{field} was changed."
240 240 mail_body_security_notification_change_to: "%{field} was changed to %{value}."
241 241 mail_body_security_notification_add: "%{field} %{value} was added."
242 242 mail_body_security_notification_remove: "%{field} %{value} was removed."
243 243 mail_body_security_notification_notify_enabled: "Email address %{value} now receives notifications."
244 244 mail_body_security_notification_notify_disabled: "Email address %{value} no longer receives notifications."
245 245 mail_body_settings_updated: "The following settings were changed:"
246 246 mail_body_password_updated: "Your password has been changed."
247 247
248 248 field_name: Name
249 249 field_description: Description
250 250 field_summary: Summary
251 251 field_is_required: Required
252 252 field_firstname: First name
253 253 field_lastname: Last name
254 254 field_mail: Email
255 255 field_address: Email
256 256 field_filename: File
257 257 field_filesize: Size
258 258 field_downloads: Downloads
259 259 field_author: Author
260 260 field_created_on: Created
261 261 field_updated_on: Updated
262 262 field_closed_on: Closed
263 263 field_field_format: Format
264 264 field_is_for_all: For all projects
265 265 field_possible_values: Possible values
266 266 field_regexp: Regular expression
267 267 field_min_length: Minimum length
268 268 field_max_length: Maximum length
269 269 field_value: Value
270 270 field_category: Category
271 271 field_title: Title
272 272 field_project: Project
273 273 field_issue: Issue
274 274 field_status: Status
275 275 field_notes: Notes
276 276 field_is_closed: Issue closed
277 277 field_is_default: Default value
278 278 field_tracker: Tracker
279 279 field_subject: Subject
280 280 field_due_date: Due date
281 281 field_assigned_to: Assignee
282 282 field_priority: Priority
283 283 field_fixed_version: Target version
284 284 field_user: User
285 285 field_principal: Principal
286 286 field_role: Role
287 287 field_homepage: Homepage
288 288 field_is_public: Public
289 289 field_parent: Subproject of
290 290 field_is_in_roadmap: Issues displayed in roadmap
291 291 field_login: Login
292 292 field_mail_notification: Email notifications
293 293 field_admin: Administrator
294 294 field_last_login_on: Last connection
295 295 field_language: Language
296 296 field_effective_date: Due date
297 297 field_password: Password
298 298 field_new_password: New password
299 299 field_password_confirmation: Confirmation
300 300 field_version: Version
301 301 field_type: Type
302 302 field_host: Host
303 303 field_port: Port
304 304 field_account: Account
305 305 field_base_dn: Base DN
306 306 field_attr_login: Login attribute
307 307 field_attr_firstname: Firstname attribute
308 308 field_attr_lastname: Lastname attribute
309 309 field_attr_mail: Email attribute
310 310 field_onthefly: On-the-fly user creation
311 311 field_start_date: Start date
312 312 field_done_ratio: "% Done"
313 313 field_auth_source: Authentication mode
314 314 field_hide_mail: Hide my email address
315 315 field_comments: Comment
316 316 field_url: URL
317 317 field_start_page: Start page
318 318 field_subproject: Subproject
319 319 field_hours: Hours
320 320 field_activity: Activity
321 321 field_spent_on: Date
322 322 field_identifier: Identifier
323 323 field_is_filter: Used as a filter
324 324 field_issue_to: Related issue
325 325 field_delay: Delay
326 326 field_assignable: Issues can be assigned to this role
327 327 field_redirect_existing_links: Redirect existing links
328 328 field_estimated_hours: Estimated time
329 329 field_column_names: Columns
330 330 field_time_entries: Log time
331 331 field_time_zone: Time zone
332 332 field_searchable: Searchable
333 333 field_default_value: Default value
334 334 field_comments_sorting: Display comments
335 335 field_parent_title: Parent page
336 336 field_editable: Editable
337 337 field_watcher: Watcher
338 338 field_identity_url: OpenID URL
339 339 field_content: Content
340 340 field_group_by: Group results by
341 341 field_sharing: Sharing
342 342 field_parent_issue: Parent task
343 343 field_member_of_group: "Assignee's group"
344 344 field_assigned_to_role: "Assignee's role"
345 345 field_text: Text field
346 346 field_visible: Visible
347 347 field_warn_on_leaving_unsaved: "Warn me when leaving a page with unsaved text"
348 348 field_issues_visibility: Issues visibility
349 349 field_is_private: Private
350 350 field_commit_logs_encoding: Commit messages encoding
351 351 field_scm_path_encoding: Path encoding
352 352 field_path_to_repository: Path to repository
353 353 field_root_directory: Root directory
354 354 field_cvsroot: CVSROOT
355 355 field_cvs_module: Module
356 356 field_repository_is_default: Main repository
357 357 field_multiple: Multiple values
358 358 field_auth_source_ldap_filter: LDAP filter
359 359 field_core_fields: Standard fields
360 360 field_timeout: "Timeout (in seconds)"
361 361 field_board_parent: Parent forum
362 362 field_private_notes: Private notes
363 363 field_inherit_members: Inherit members
364 364 field_generate_password: Generate password
365 365 field_must_change_passwd: Must change password at next logon
366 366 field_default_status: Default status
367 367 field_users_visibility: Users visibility
368 368 field_time_entries_visibility: Time logs visibility
369 369 field_total_estimated_hours: Total estimated time
370 370 field_default_version: Default version
371 371 field_remote_ip: IP address
372 372 field_textarea_font: Font used for text areas
373 field_updated_by: Updated by
374 field_last_updated_by: Last updated by
373 375
374 376 setting_app_title: Application title
375 377 setting_app_subtitle: Application subtitle
376 378 setting_welcome_text: Welcome text
377 379 setting_default_language: Default language
378 380 setting_login_required: Authentication required
379 381 setting_self_registration: Self-registration
380 382 setting_attachment_max_size: Maximum attachment size
381 383 setting_issues_export_limit: Issues export limit
382 384 setting_mail_from: Emission email address
383 385 setting_bcc_recipients: Blind carbon copy recipients (bcc)
384 386 setting_plain_text_mail: Plain text mail (no HTML)
385 387 setting_host_name: Host name and path
386 388 setting_text_formatting: Text formatting
387 389 setting_wiki_compression: Wiki history compression
388 390 setting_feeds_limit: Maximum number of items in Atom feeds
389 391 setting_default_projects_public: New projects are public by default
390 392 setting_autofetch_changesets: Fetch commits automatically
391 393 setting_sys_api_enabled: Enable WS for repository management
392 394 setting_commit_ref_keywords: Referencing keywords
393 395 setting_commit_fix_keywords: Fixing keywords
394 396 setting_autologin: Autologin
395 397 setting_date_format: Date format
396 398 setting_time_format: Time format
397 399 setting_timespan_format: Time span format
398 400 setting_cross_project_issue_relations: Allow cross-project issue relations
399 401 setting_cross_project_subtasks: Allow cross-project subtasks
400 402 setting_issue_list_default_columns: Default columns displayed on the issue list
401 403 setting_repositories_encodings: Attachments and repositories encodings
402 404 setting_emails_header: Email header
403 405 setting_emails_footer: Email footer
404 406 setting_protocol: Protocol
405 407 setting_per_page_options: Objects per page options
406 408 setting_user_format: Users display format
407 409 setting_activity_days_default: Days displayed on project activity
408 410 setting_display_subprojects_issues: Display subprojects issues on main projects by default
409 411 setting_enabled_scm: Enabled SCM
410 412 setting_mail_handler_body_delimiters: "Truncate emails after one of these lines"
411 413 setting_mail_handler_enable_regex_delimiters: "Enable regular expressions"
412 414 setting_mail_handler_api_enabled: Enable WS for incoming emails
413 415 setting_mail_handler_api_key: Incoming email WS API key
414 416 setting_sys_api_key: Repository management WS API key
415 417 setting_sequential_project_identifiers: Generate sequential project identifiers
416 418 setting_gravatar_enabled: Use Gravatar user icons
417 419 setting_gravatar_default: Default Gravatar image
418 420 setting_diff_max_lines_displayed: Maximum number of diff lines displayed
419 421 setting_file_max_size_displayed: Maximum size of text files displayed inline
420 422 setting_repository_log_display_limit: Maximum number of revisions displayed on file log
421 423 setting_openid: Allow OpenID login and registration
422 424 setting_password_max_age: Require password change after
423 425 setting_password_min_length: Minimum password length
424 426 setting_lost_password: Allow password reset via email
425 427 setting_new_project_user_role_id: Role given to a non-admin user who creates a project
426 428 setting_default_projects_modules: Default enabled modules for new projects
427 429 setting_issue_done_ratio: Calculate the issue done ratio with
428 430 setting_issue_done_ratio_issue_field: Use the issue field
429 431 setting_issue_done_ratio_issue_status: Use the issue status
430 432 setting_start_of_week: Start calendars on
431 433 setting_rest_api_enabled: Enable REST web service
432 434 setting_cache_formatted_text: Cache formatted text
433 435 setting_default_notification_option: Default notification option
434 436 setting_commit_logtime_enabled: Enable time logging
435 437 setting_commit_logtime_activity_id: Activity for logged time
436 438 setting_gantt_items_limit: Maximum number of items displayed on the gantt chart
437 439 setting_issue_group_assignment: Allow issue assignment to groups
438 440 setting_default_issue_start_date_to_creation_date: Use current date as start date for new issues
439 441 setting_commit_cross_project_ref: Allow issues of all the other projects to be referenced and fixed
440 442 setting_unsubscribe: Allow users to delete their own account
441 443 setting_session_lifetime: Session maximum lifetime
442 444 setting_session_timeout: Session inactivity timeout
443 445 setting_thumbnails_enabled: Display attachment thumbnails
444 446 setting_thumbnails_size: Thumbnails size (in pixels)
445 447 setting_non_working_week_days: Non-working days
446 448 setting_jsonp_enabled: Enable JSONP support
447 449 setting_default_projects_tracker_ids: Default trackers for new projects
448 450 setting_mail_handler_excluded_filenames: Exclude attachments by name
449 451 setting_force_default_language_for_anonymous: Force default language for anonymous users
450 452 setting_force_default_language_for_loggedin: Force default language for logged-in users
451 453 setting_link_copied_issue: Link issues on copy
452 454 setting_max_additional_emails: Maximum number of additional email addresses
453 455 setting_search_results_per_page: Search results per page
454 456 setting_attachment_extensions_allowed: Allowed extensions
455 457 setting_attachment_extensions_denied: Disallowed extensions
456 458 setting_new_item_menu_tab: Project menu tab for creating new objects
457 459 setting_commit_logs_formatting: Apply text formatting to commit messages
458 460 setting_timelog_required_fields: Required fields for time logs
459 461
460 462 permission_add_project: Create project
461 463 permission_add_subprojects: Create subprojects
462 464 permission_edit_project: Edit project
463 465 permission_close_project: Close / reopen the project
464 466 permission_select_project_modules: Select project modules
465 467 permission_manage_members: Manage members
466 468 permission_manage_project_activities: Manage project activities
467 469 permission_manage_versions: Manage versions
468 470 permission_manage_categories: Manage issue categories
469 471 permission_view_issues: View Issues
470 472 permission_add_issues: Add issues
471 473 permission_edit_issues: Edit issues
472 474 permission_copy_issues: Copy issues
473 475 permission_manage_issue_relations: Manage issue relations
474 476 permission_set_issues_private: Set issues public or private
475 477 permission_set_own_issues_private: Set own issues public or private
476 478 permission_add_issue_notes: Add notes
477 479 permission_edit_issue_notes: Edit notes
478 480 permission_edit_own_issue_notes: Edit own notes
479 481 permission_view_private_notes: View private notes
480 482 permission_set_notes_private: Set notes as private
481 483 permission_move_issues: Move issues
482 484 permission_delete_issues: Delete issues
483 485 permission_manage_public_queries: Manage public queries
484 486 permission_save_queries: Save queries
485 487 permission_view_gantt: View gantt chart
486 488 permission_view_calendar: View calendar
487 489 permission_view_issue_watchers: View watchers list
488 490 permission_add_issue_watchers: Add watchers
489 491 permission_delete_issue_watchers: Delete watchers
490 492 permission_log_time: Log spent time
491 493 permission_view_time_entries: View spent time
492 494 permission_edit_time_entries: Edit time logs
493 495 permission_edit_own_time_entries: Edit own time logs
494 496 permission_manage_news: Manage news
495 497 permission_comment_news: Comment news
496 498 permission_view_documents: View documents
497 499 permission_add_documents: Add documents
498 500 permission_edit_documents: Edit documents
499 501 permission_delete_documents: Delete documents
500 502 permission_manage_files: Manage files
501 503 permission_view_files: View files
502 504 permission_manage_wiki: Manage wiki
503 505 permission_rename_wiki_pages: Rename wiki pages
504 506 permission_delete_wiki_pages: Delete wiki pages
505 507 permission_view_wiki_pages: View wiki
506 508 permission_view_wiki_edits: View wiki history
507 509 permission_edit_wiki_pages: Edit wiki pages
508 510 permission_delete_wiki_pages_attachments: Delete attachments
509 511 permission_protect_wiki_pages: Protect wiki pages
510 512 permission_manage_repository: Manage repository
511 513 permission_browse_repository: Browse repository
512 514 permission_view_changesets: View changesets
513 515 permission_commit_access: Commit access
514 516 permission_manage_boards: Manage forums
515 517 permission_view_messages: View messages
516 518 permission_add_messages: Post messages
517 519 permission_edit_messages: Edit messages
518 520 permission_edit_own_messages: Edit own messages
519 521 permission_delete_messages: Delete messages
520 522 permission_delete_own_messages: Delete own messages
521 523 permission_export_wiki_pages: Export wiki pages
522 524 permission_manage_subtasks: Manage subtasks
523 525 permission_manage_related_issues: Manage related issues
524 526 permission_import_issues: Import issues
525 527
526 528 project_module_issue_tracking: Issue tracking
527 529 project_module_time_tracking: Time tracking
528 530 project_module_news: News
529 531 project_module_documents: Documents
530 532 project_module_files: Files
531 533 project_module_wiki: Wiki
532 534 project_module_repository: Repository
533 535 project_module_boards: Forums
534 536 project_module_calendar: Calendar
535 537 project_module_gantt: Gantt
536 538
537 539 label_user: User
538 540 label_user_plural: Users
539 541 label_user_new: New user
540 542 label_user_anonymous: Anonymous
541 543 label_project: Project
542 544 label_project_new: New project
543 545 label_project_plural: Projects
544 546 label_x_projects:
545 547 zero: no projects
546 548 one: 1 project
547 549 other: "%{count} projects"
548 550 label_project_all: All Projects
549 551 label_project_latest: Latest projects
550 552 label_issue: Issue
551 553 label_issue_new: New issue
552 554 label_issue_plural: Issues
553 555 label_issue_view_all: View all issues
554 556 label_issues_by: "Issues by %{value}"
555 557 label_issue_added: Issue added
556 558 label_issue_updated: Issue updated
557 559 label_issue_note_added: Note added
558 560 label_issue_status_updated: Status updated
559 561 label_issue_assigned_to_updated: Assignee updated
560 562 label_issue_priority_updated: Priority updated
561 563 label_document: Document
562 564 label_document_new: New document
563 565 label_document_plural: Documents
564 566 label_document_added: Document added
565 567 label_role: Role
566 568 label_role_plural: Roles
567 569 label_role_new: New role
568 570 label_role_and_permissions: Roles and permissions
569 571 label_role_anonymous: Anonymous
570 572 label_role_non_member: Non member
571 573 label_member: Member
572 574 label_member_new: New member
573 575 label_member_plural: Members
574 576 label_tracker: Tracker
575 577 label_tracker_plural: Trackers
576 578 label_tracker_all: All trackers
577 579 label_tracker_new: New tracker
578 580 label_workflow: Workflow
579 581 label_issue_status: Issue status
580 582 label_issue_status_plural: Issue statuses
581 583 label_issue_status_new: New status
582 584 label_issue_category: Issue category
583 585 label_issue_category_plural: Issue categories
584 586 label_issue_category_new: New category
585 587 label_custom_field: Custom field
586 588 label_custom_field_plural: Custom fields
587 589 label_custom_field_new: New custom field
588 590 label_enumerations: Enumerations
589 591 label_enumeration_new: New value
590 592 label_information: Information
591 593 label_information_plural: Information
592 594 label_please_login: Please log in
593 595 label_register: Register
594 596 label_login_with_open_id_option: or login with OpenID
595 597 label_password_lost: Lost password
596 598 label_password_required: Confirm your password to continue
597 599 label_home: Home
598 600 label_my_page: My page
599 601 label_my_account: My account
600 602 label_my_projects: My projects
601 603 label_my_page_block: My page block
602 604 label_administration: Administration
603 605 label_login: Sign in
604 606 label_logout: Sign out
605 607 label_help: Help
606 608 label_reported_issues: Reported issues
607 609 label_assigned_issues: Assigned issues
608 610 label_assigned_to_me_issues: Issues assigned to me
609 611 label_last_login: Last connection
610 612 label_registered_on: Registered on
611 613 label_activity: Activity
612 614 label_overall_activity: Overall activity
613 615 label_user_activity: "%{value}'s activity"
614 616 label_new: New
615 617 label_logged_as: Logged in as
616 618 label_environment: Environment
617 619 label_authentication: Authentication
618 620 label_auth_source: Authentication mode
619 621 label_auth_source_new: New authentication mode
620 622 label_auth_source_plural: Authentication modes
621 623 label_subproject_plural: Subprojects
622 624 label_subproject_new: New subproject
623 625 label_and_its_subprojects: "%{value} and its subprojects"
624 626 label_min_max_length: Min - Max length
625 627 label_list: List
626 628 label_date: Date
627 629 label_integer: Integer
628 630 label_float: Float
629 631 label_boolean: Boolean
630 632 label_string: Text
631 633 label_text: Long text
632 634 label_attribute: Attribute
633 635 label_attribute_plural: Attributes
634 636 label_no_data: No data to display
635 637 label_no_preview: No preview available
636 638 label_change_status: Change status
637 639 label_history: History
638 640 label_attachment: File
639 641 label_attachment_new: New file
640 642 label_attachment_delete: Delete file
641 643 label_attachment_plural: Files
642 644 label_file_added: File added
643 645 label_report: Report
644 646 label_report_plural: Reports
645 647 label_news: News
646 648 label_news_new: Add news
647 649 label_news_plural: News
648 650 label_news_latest: Latest news
649 651 label_news_view_all: View all news
650 652 label_news_added: News added
651 653 label_news_comment_added: Comment added to a news
652 654 label_settings: Settings
653 655 label_overview: Overview
654 656 label_version: Version
655 657 label_version_new: New version
656 658 label_version_plural: Versions
657 659 label_close_versions: Close completed versions
658 660 label_confirmation: Confirmation
659 661 label_export_to: 'Also available in:'
660 662 label_read: Read...
661 663 label_public_projects: Public projects
662 664 label_open_issues: open
663 665 label_open_issues_plural: open
664 666 label_closed_issues: closed
665 667 label_closed_issues_plural: closed
666 668 label_x_open_issues_abbr:
667 669 zero: 0 open
668 670 one: 1 open
669 671 other: "%{count} open"
670 672 label_x_closed_issues_abbr:
671 673 zero: 0 closed
672 674 one: 1 closed
673 675 other: "%{count} closed"
674 676 label_x_issues:
675 677 zero: 0 issues
676 678 one: 1 issue
677 679 other: "%{count} issues"
678 680 label_total: Total
679 681 label_total_plural: Totals
680 682 label_total_time: Total time
681 683 label_permissions: Permissions
682 684 label_current_status: Current status
683 685 label_new_statuses_allowed: New statuses allowed
684 686 label_all: all
685 687 label_any: any
686 688 label_none: none
687 689 label_nobody: nobody
688 690 label_next: Next
689 691 label_previous: Previous
690 692 label_used_by: Used by
691 693 label_details: Details
692 694 label_add_note: Add a note
693 695 label_calendar: Calendar
694 696 label_months_from: months from
695 697 label_gantt: Gantt
696 698 label_internal: Internal
697 699 label_last_changes: "last %{count} changes"
698 700 label_change_view_all: View all changes
699 701 label_personalize_page: Personalize this page
700 702 label_comment: Comment
701 703 label_comment_plural: Comments
702 704 label_x_comments:
703 705 zero: no comments
704 706 one: 1 comment
705 707 other: "%{count} comments"
706 708 label_comment_add: Add a comment
707 709 label_comment_added: Comment added
708 710 label_comment_delete: Delete comments
709 711 label_query: Custom query
710 712 label_query_plural: Custom queries
711 713 label_query_new: New query
712 714 label_my_queries: My custom queries
713 715 label_filter_add: Add filter
714 716 label_filter_plural: Filters
715 717 label_equals: is
716 718 label_not_equals: is not
717 719 label_in_less_than: in less than
718 720 label_in_more_than: in more than
719 721 label_in_the_next_days: in the next
720 722 label_in_the_past_days: in the past
721 723 label_greater_or_equal: '>='
722 724 label_less_or_equal: '<='
723 725 label_between: between
724 726 label_in: in
725 727 label_today: today
726 728 label_all_time: all time
727 729 label_yesterday: yesterday
728 730 label_this_week: this week
729 731 label_last_week: last week
730 732 label_last_n_weeks: "last %{count} weeks"
731 733 label_last_n_days: "last %{count} days"
732 734 label_this_month: this month
733 735 label_last_month: last month
734 736 label_this_year: this year
735 737 label_date_range: Date range
736 738 label_less_than_ago: less than days ago
737 739 label_more_than_ago: more than days ago
738 740 label_ago: days ago
739 741 label_contains: contains
740 742 label_not_contains: doesn't contain
741 743 label_any_issues_in_project: any issues in project
742 744 label_any_issues_not_in_project: any issues not in project
743 745 label_no_issues_in_project: no issues in project
744 746 label_any_open_issues: any open issues
745 747 label_no_open_issues: no open issues
746 748 label_day_plural: days
747 749 label_repository: Repository
748 750 label_repository_new: New repository
749 751 label_repository_plural: Repositories
750 752 label_browse: Browse
751 753 label_branch: Branch
752 754 label_tag: Tag
753 755 label_revision: Revision
754 756 label_revision_plural: Revisions
755 757 label_revision_id: "Revision %{value}"
756 758 label_associated_revisions: Associated revisions
757 759 label_added: added
758 760 label_modified: modified
759 761 label_copied: copied
760 762 label_renamed: renamed
761 763 label_deleted: deleted
762 764 label_latest_revision: Latest revision
763 765 label_latest_revision_plural: Latest revisions
764 766 label_view_revisions: View revisions
765 767 label_view_all_revisions: View all revisions
766 768 label_max_size: Maximum size
767 769 label_sort_highest: Move to top
768 770 label_sort_higher: Move up
769 771 label_sort_lower: Move down
770 772 label_sort_lowest: Move to bottom
771 773 label_roadmap: Roadmap
772 774 label_roadmap_due_in: "Due in %{value}"
773 775 label_roadmap_overdue: "%{value} late"
774 776 label_roadmap_no_issues: No issues for this version
775 777 label_search: Search
776 778 label_result_plural: Results
777 779 label_all_words: All words
778 780 label_wiki: Wiki
779 781 label_wiki_edit: Wiki edit
780 782 label_wiki_edit_plural: Wiki edits
781 783 label_wiki_page: Wiki page
782 784 label_wiki_page_plural: Wiki pages
783 785 label_wiki_page_new: New wiki page
784 786 label_index_by_title: Index by title
785 787 label_index_by_date: Index by date
786 788 label_current_version: Current version
787 789 label_preview: Preview
788 790 label_feed_plural: Feeds
789 791 label_changes_details: Details of all changes
790 792 label_issue_tracking: Issue tracking
791 793 label_spent_time: Spent time
792 794 label_total_spent_time: Total spent time
793 795 label_overall_spent_time: Overall spent time
794 796 label_f_hour: "%{value} hour"
795 797 label_f_hour_plural: "%{value} hours"
796 798 label_f_hour_short: "%{value} h"
797 799 label_time_tracking: Time tracking
798 800 label_change_plural: Changes
799 801 label_statistics: Statistics
800 802 label_commits_per_month: Commits per month
801 803 label_commits_per_author: Commits per author
802 804 label_diff: diff
803 805 label_view_diff: View differences
804 806 label_diff_inline: inline
805 807 label_diff_side_by_side: side by side
806 808 label_options: Options
807 809 label_copy_workflow_from: Copy workflow from
808 810 label_permissions_report: Permissions report
809 811 label_watched_issues: Watched issues
810 812 label_related_issues: Related issues
811 813 label_applied_status: Applied status
812 814 label_loading: Loading...
813 815 label_relation_new: New relation
814 816 label_relation_delete: Delete relation
815 817 label_relates_to: Related to
816 818 label_duplicates: Duplicates
817 819 label_duplicated_by: Duplicated by
818 820 label_blocks: Blocks
819 821 label_blocked_by: Blocked by
820 822 label_precedes: Precedes
821 823 label_follows: Follows
822 824 label_copied_to: Copied to
823 825 label_copied_from: Copied from
824 826 label_stay_logged_in: Stay logged in
825 827 label_disabled: disabled
826 828 label_show_completed_versions: Show completed versions
827 829 label_me: me
828 830 label_board: Forum
829 831 label_board_new: New forum
830 832 label_board_plural: Forums
831 833 label_board_locked: Locked
832 834 label_board_sticky: Sticky
833 835 label_topic_plural: Topics
834 836 label_message_plural: Messages
835 837 label_message_last: Last message
836 838 label_message_new: New message
837 839 label_message_posted: Message added
838 840 label_reply_plural: Replies
839 841 label_send_information: Send account information to the user
840 842 label_year: Year
841 843 label_month: Month
842 844 label_week: Week
843 845 label_date_from: From
844 846 label_date_to: To
845 847 label_language_based: Based on user's language
846 848 label_sort_by: "Sort by %{value}"
847 849 label_send_test_email: Send a test email
848 850 label_feeds_access_key: Atom access key
849 851 label_missing_feeds_access_key: Missing a Atom access key
850 852 label_feeds_access_key_created_on: "Atom access key created %{value} ago"
851 853 label_module_plural: Modules
852 854 label_added_time_by: "Added by %{author} %{age} ago"
853 855 label_updated_time_by: "Updated by %{author} %{age} ago"
854 856 label_updated_time: "Updated %{value} ago"
855 857 label_jump_to_a_project: Jump to a project...
856 858 label_file_plural: Files
857 859 label_changeset_plural: Changesets
858 860 label_default_columns: Default columns
859 861 label_no_change_option: (No change)
860 862 label_bulk_edit_selected_issues: Bulk edit selected issues
861 863 label_bulk_edit_selected_time_entries: Bulk edit selected time entries
862 864 label_theme: Theme
863 865 label_default: Default
864 866 label_search_titles_only: Search titles only
865 867 label_user_mail_option_all: "For any event on all my projects"
866 868 label_user_mail_option_selected: "For any event on the selected projects only..."
867 869 label_user_mail_option_none: "No events"
868 870 label_user_mail_option_only_my_events: "Only for things I watch or I'm involved in"
869 871 label_user_mail_option_only_assigned: "Only for things I watch or I am assigned to"
870 872 label_user_mail_option_only_owner: "Only for things I watch or I am the owner of"
871 873 label_user_mail_no_self_notified: "I don't want to be notified of changes that I make myself"
872 874 label_registration_activation_by_email: account activation by email
873 875 label_registration_manual_activation: manual account activation
874 876 label_registration_automatic_activation: automatic account activation
875 877 label_display_per_page: "Per page: %{value}"
876 878 label_age: Age
877 879 label_change_properties: Change properties
878 880 label_general: General
879 881 label_more: More
880 882 label_scm: SCM
881 883 label_plugins: Plugins
882 884 label_ldap_authentication: LDAP authentication
883 885 label_downloads_abbr: D/L
884 886 label_optional_description: Optional description
885 887 label_add_another_file: Add another file
886 888 label_preferences: Preferences
887 889 label_chronological_order: In chronological order
888 890 label_reverse_chronological_order: In reverse chronological order
889 891 label_planning: Planning
890 892 label_incoming_emails: Incoming emails
891 893 label_generate_key: Generate a key
892 894 label_issue_watchers: Watchers
893 895 label_example: Example
894 896 label_display: Display
895 897 label_sort: Sort
896 898 label_ascending: Ascending
897 899 label_descending: Descending
898 900 label_date_from_to: From %{start} to %{end}
899 901 label_wiki_content_added: Wiki page added
900 902 label_wiki_content_updated: Wiki page updated
901 903 label_group: Group
902 904 label_group_plural: Groups
903 905 label_group_new: New group
904 906 label_group_anonymous: Anonymous users
905 907 label_group_non_member: Non member users
906 908 label_time_entry_plural: Spent time
907 909 label_version_sharing_none: Not shared
908 910 label_version_sharing_descendants: With subprojects
909 911 label_version_sharing_hierarchy: With project hierarchy
910 912 label_version_sharing_tree: With project tree
911 913 label_version_sharing_system: With all projects
912 914 label_update_issue_done_ratios: Update issue done ratios
913 915 label_copy_source: Source
914 916 label_copy_target: Target
915 917 label_copy_same_as_target: Same as target
916 918 label_display_used_statuses_only: Only display statuses that are used by this tracker
917 919 label_api_access_key: API access key
918 920 label_missing_api_access_key: Missing an API access key
919 921 label_api_access_key_created_on: "API access key created %{value} ago"
920 922 label_profile: Profile
921 923 label_subtask_plural: Subtasks
922 924 label_project_copy_notifications: Send email notifications during the project copy
923 925 label_principal_search: "Search for user or group:"
924 926 label_user_search: "Search for user:"
925 927 label_additional_workflow_transitions_for_author: Additional transitions allowed when the user is the author
926 928 label_additional_workflow_transitions_for_assignee: Additional transitions allowed when the user is the assignee
927 929 label_issues_visibility_all: All issues
928 930 label_issues_visibility_public: All non private issues
929 931 label_issues_visibility_own: Issues created by or assigned to the user
930 932 label_git_report_last_commit: Report last commit for files and directories
931 933 label_parent_revision: Parent
932 934 label_child_revision: Child
933 935 label_export_options: "%{export_format} export options"
934 936 label_copy_attachments: Copy attachments
935 937 label_copy_subtasks: Copy subtasks
936 938 label_item_position: "%{position} of %{count}"
937 939 label_completed_versions: Completed versions
938 940 label_search_for_watchers: Search for watchers to add
939 941 label_session_expiration: Session expiration
940 942 label_show_closed_projects: View closed projects
941 943 label_status_transitions: Status transitions
942 944 label_fields_permissions: Fields permissions
943 945 label_readonly: Read-only
944 946 label_required: Required
945 947 label_hidden: Hidden
946 948 label_attribute_of_project: "Project's %{name}"
947 949 label_attribute_of_issue: "Issue's %{name}"
948 950 label_attribute_of_author: "Author's %{name}"
949 951 label_attribute_of_assigned_to: "Assignee's %{name}"
950 952 label_attribute_of_user: "User's %{name}"
951 953 label_attribute_of_fixed_version: "Target version's %{name}"
952 954 label_attribute_of_object: "%{object_name}'s %{name}"
953 955 label_cross_project_descendants: With subprojects
954 956 label_cross_project_tree: With project tree
955 957 label_cross_project_hierarchy: With project hierarchy
956 958 label_cross_project_system: With all projects
957 959 label_gantt_progress_line: Progress line
958 960 label_visibility_private: to me only
959 961 label_visibility_roles: to these roles only
960 962 label_visibility_public: to any users
961 963 label_link: Link
962 964 label_only: only
963 965 label_drop_down_list: drop-down list
964 966 label_checkboxes: checkboxes
965 967 label_radio_buttons: radio buttons
966 968 label_link_values_to: Link values to URL
967 969 label_custom_field_select_type: Select the type of object to which the custom field is to be attached
968 970 label_check_for_updates: Check for updates
969 971 label_latest_compatible_version: Latest compatible version
970 972 label_unknown_plugin: Unknown plugin
971 973 label_add_projects: Add projects
972 974 label_users_visibility_all: All active users
973 975 label_users_visibility_members_of_visible_projects: Members of visible projects
974 976 label_edit_attachments: Edit attached files
975 977 label_link_copied_issue: Link copied issue
976 978 label_ask: Ask
977 979 label_search_attachments_yes: Search attachment filenames and descriptions
978 980 label_search_attachments_no: Do not search attachments
979 981 label_search_attachments_only: Search attachments only
980 982 label_search_open_issues_only: Open issues only
981 983 label_email_address_plural: Emails
982 984 label_email_address_add: Add email address
983 985 label_enable_notifications: Enable notifications
984 986 label_disable_notifications: Disable notifications
985 987 label_blank_value: blank
986 988 label_parent_task_attributes: Parent tasks attributes
987 989 label_parent_task_attributes_derived: Calculated from subtasks
988 990 label_parent_task_attributes_independent: Independent of subtasks
989 991 label_time_entries_visibility_all: All time entries
990 992 label_time_entries_visibility_own: Time entries created by the user
991 993 label_member_management: Member management
992 994 label_member_management_all_roles: All roles
993 995 label_member_management_selected_roles_only: Only these roles
994 996 label_import_issues: Import issues
995 997 label_select_file_to_import: Select the file to import
996 998 label_fields_separator: Field separator
997 999 label_fields_wrapper: Field wrapper
998 1000 label_encoding: Encoding
999 1001 label_comma_char: Comma
1000 1002 label_semi_colon_char: Semicolon
1001 1003 label_quote_char: Quote
1002 1004 label_double_quote_char: Double quote
1003 1005 label_fields_mapping: Fields mapping
1004 1006 label_file_content_preview: File content preview
1005 1007 label_create_missing_values: Create missing values
1006 1008 label_api: API
1007 1009 label_field_format_enumeration: Key/value list
1008 1010 label_default_values_for_new_users: Default values for new users
1009 1011 label_relations: Relations
1010 1012 label_new_project_issue_tab_enabled: Display the "New issue" tab
1011 1013 label_new_object_tab_enabled: Display the "+" drop-down
1012 1014 label_table_of_contents: Table of contents
1013 1015 label_font_default: Default font
1014 1016 label_font_monospace: Monospaced font
1015 1017 label_font_proportional: Proportional font
1016 1018
1017 1019 button_login: Login
1018 1020 button_submit: Submit
1019 1021 button_save: Save
1020 1022 button_check_all: Check all
1021 1023 button_uncheck_all: Uncheck all
1022 1024 button_collapse_all: Collapse all
1023 1025 button_expand_all: Expand all
1024 1026 button_delete: Delete
1025 1027 button_create: Create
1026 1028 button_create_and_continue: Create and continue
1027 1029 button_test: Test
1028 1030 button_edit: Edit
1029 1031 button_edit_associated_wikipage: "Edit associated Wiki page: %{page_title}"
1030 1032 button_add: Add
1031 1033 button_change: Change
1032 1034 button_apply: Apply
1033 1035 button_clear: Clear
1034 1036 button_lock: Lock
1035 1037 button_unlock: Unlock
1036 1038 button_download: Download
1037 1039 button_list: List
1038 1040 button_view: View
1039 1041 button_move: Move
1040 1042 button_move_and_follow: Move and follow
1041 1043 button_back: Back
1042 1044 button_cancel: Cancel
1043 1045 button_activate: Activate
1044 1046 button_sort: Sort
1045 1047 button_log_time: Log time
1046 1048 button_rollback: Rollback to this version
1047 1049 button_watch: Watch
1048 1050 button_unwatch: Unwatch
1049 1051 button_reply: Reply
1050 1052 button_archive: Archive
1051 1053 button_unarchive: Unarchive
1052 1054 button_reset: Reset
1053 1055 button_rename: Rename
1054 1056 button_change_password: Change password
1055 1057 button_copy: Copy
1056 1058 button_copy_and_follow: Copy and follow
1057 1059 button_annotate: Annotate
1058 1060 button_update: Update
1059 1061 button_configure: Configure
1060 1062 button_quote: Quote
1061 1063 button_duplicate: Duplicate
1062 1064 button_show: Show
1063 1065 button_hide: Hide
1064 1066 button_edit_section: Edit this section
1065 1067 button_export: Export
1066 1068 button_delete_my_account: Delete my account
1067 1069 button_close: Close
1068 1070 button_reopen: Reopen
1069 1071 button_import: Import
1070 1072 button_filter: Filter
1071 1073
1072 1074 status_active: active
1073 1075 status_registered: registered
1074 1076 status_locked: locked
1075 1077
1076 1078 project_status_active: active
1077 1079 project_status_closed: closed
1078 1080 project_status_archived: archived
1079 1081
1080 1082 version_status_open: open
1081 1083 version_status_locked: locked
1082 1084 version_status_closed: closed
1083 1085
1084 1086 field_active: Active
1085 1087
1086 1088 text_select_mail_notifications: Select actions for which email notifications should be sent.
1087 1089 text_regexp_info: eg. ^[A-Z0-9]+$
1088 1090 text_min_max_length_info: 0 means no restriction
1089 1091 text_project_destroy_confirmation: Are you sure you want to delete this project and related data?
1090 1092 text_subprojects_destroy_warning: "Its subproject(s): %{value} will be also deleted."
1091 1093 text_workflow_edit: Select a role and a tracker to edit the workflow
1092 1094 text_are_you_sure: Are you sure?
1093 1095 text_journal_changed: "%{label} changed from %{old} to %{new}"
1094 1096 text_journal_changed_no_detail: "%{label} updated"
1095 1097 text_journal_set_to: "%{label} set to %{value}"
1096 1098 text_journal_deleted: "%{label} deleted (%{old})"
1097 1099 text_journal_added: "%{label} %{value} added"
1098 1100 text_tip_issue_begin_day: issue beginning this day
1099 1101 text_tip_issue_end_day: issue ending this day
1100 1102 text_tip_issue_begin_end_day: issue beginning and ending this day
1101 1103 text_project_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed, must start with a lower case letter.<br />Once saved, the identifier cannot be changed.'
1102 1104 text_caracters_maximum: "%{count} characters maximum."
1103 1105 text_caracters_minimum: "Must be at least %{count} characters long."
1104 1106 text_length_between: "Length between %{min} and %{max} characters."
1105 1107 text_tracker_no_workflow: No workflow defined for this tracker
1106 1108 text_unallowed_characters: Unallowed characters
1107 1109 text_comma_separated: Multiple values allowed (comma separated).
1108 1110 text_line_separated: Multiple values allowed (one line for each value).
1109 1111 text_issues_ref_in_commit_messages: Referencing and fixing issues in commit messages
1110 1112 text_issue_added: "Issue %{id} has been reported by %{author}."
1111 1113 text_issue_updated: "Issue %{id} has been updated by %{author}."
1112 1114 text_wiki_destroy_confirmation: Are you sure you want to delete this wiki and all its content?
1113 1115 text_issue_category_destroy_question: "Some issues (%{count}) are assigned to this category. What do you want to do?"
1114 1116 text_issue_category_destroy_assignments: Remove category assignments
1115 1117 text_issue_category_reassign_to: Reassign issues to this category
1116 1118 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)."
1117 1119 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."
1118 1120 text_load_default_configuration: Load the default configuration
1119 1121 text_status_changed_by_changeset: "Applied in changeset %{value}."
1120 1122 text_time_logged_by_changeset: "Applied in changeset %{value}."
1121 1123 text_issues_destroy_confirmation: 'Are you sure you want to delete the selected issue(s)?'
1122 1124 text_issues_destroy_descendants_confirmation: "This will also delete %{count} subtask(s)."
1123 1125 text_time_entries_destroy_confirmation: 'Are you sure you want to delete the selected time entr(y/ies)?'
1124 1126 text_select_project_modules: 'Select modules to enable for this project:'
1125 1127 text_default_administrator_account_changed: Default administrator account changed
1126 1128 text_file_repository_writable: Attachments directory writable
1127 1129 text_plugin_assets_writable: Plugin assets directory writable
1128 1130 text_rmagick_available: RMagick available (optional)
1129 1131 text_convert_available: ImageMagick convert available (optional)
1130 1132 text_destroy_time_entries_question: "%{hours} hours were reported on the issues you are about to delete. What do you want to do?"
1131 1133 text_destroy_time_entries: Delete reported hours
1132 1134 text_assign_time_entries_to_project: Assign reported hours to the project
1133 1135 text_reassign_time_entries: 'Reassign reported hours to this issue:'
1134 1136 text_user_wrote: "%{value} wrote:"
1135 1137 text_enumeration_destroy_question: "%{count} objects are assigned to the value “%{name}”."
1136 1138 text_enumeration_category_reassign_to: 'Reassign them to this value:'
1137 1139 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."
1138 1140 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."
1139 1141 text_diff_truncated: '... This diff was truncated because it exceeds the maximum size that can be displayed.'
1140 1142 text_custom_field_possible_values_info: 'One line for each value'
1141 1143 text_wiki_page_destroy_question: "This page has %{descendants} child page(s) and descendant(s). What do you want to do?"
1142 1144 text_wiki_page_nullify_children: "Keep child pages as root pages"
1143 1145 text_wiki_page_destroy_children: "Delete child pages and all their descendants"
1144 1146 text_wiki_page_reassign_children: "Reassign child pages to this parent page"
1145 1147 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?"
1146 1148 text_zoom_in: Zoom in
1147 1149 text_zoom_out: Zoom out
1148 1150 text_warn_on_leaving_unsaved: "The current page contains unsaved text that will be lost if you leave this page."
1149 1151 text_scm_path_encoding_note: "Default: UTF-8"
1150 1152 text_subversion_repository_note: "Examples: file:///, http://, https://, svn://, svn+[tunnelscheme]://"
1151 1153 text_git_repository_note: Repository is bare and local (e.g. /gitrepo, c:\gitrepo)
1152 1154 text_mercurial_repository_note: Local repository (e.g. /hgrepo, c:\hgrepo)
1153 1155 text_scm_command: Command
1154 1156 text_scm_command_version: Version
1155 1157 text_scm_config: You can configure your SCM commands in config/configuration.yml. Please restart the application after editing it.
1156 1158 text_scm_command_not_available: SCM command is not available. Please check settings on the administration panel.
1157 1159 text_issue_conflict_resolution_overwrite: "Apply my changes anyway (previous notes will be kept but some changes may be overwritten)"
1158 1160 text_issue_conflict_resolution_add_notes: "Add my notes and discard my other changes"
1159 1161 text_issue_conflict_resolution_cancel: "Discard all my changes and redisplay %{link}"
1160 1162 text_account_destroy_confirmation: "Are you sure you want to proceed?\nYour account will be permanently deleted, with no way to reactivate it."
1161 1163 text_session_expiration_settings: "Warning: changing these settings may expire the current sessions including yours."
1162 1164 text_project_closed: This project is closed and read-only.
1163 1165 text_turning_multiple_off: "If you disable multiple values, multiple values will be removed in order to preserve only one value per item."
1164 1166
1165 1167 default_role_manager: Manager
1166 1168 default_role_developer: Developer
1167 1169 default_role_reporter: Reporter
1168 1170 default_tracker_bug: Bug
1169 1171 default_tracker_feature: Feature
1170 1172 default_tracker_support: Support
1171 1173 default_issue_status_new: New
1172 1174 default_issue_status_in_progress: In Progress
1173 1175 default_issue_status_resolved: Resolved
1174 1176 default_issue_status_feedback: Feedback
1175 1177 default_issue_status_closed: Closed
1176 1178 default_issue_status_rejected: Rejected
1177 1179 default_doc_category_user: User documentation
1178 1180 default_doc_category_tech: Technical documentation
1179 1181 default_priority_low: Low
1180 1182 default_priority_normal: Normal
1181 1183 default_priority_high: High
1182 1184 default_priority_urgent: Urgent
1183 1185 default_priority_immediate: Immediate
1184 1186 default_activity_design: Design
1185 1187 default_activity_development: Development
1186 1188
1187 1189 enumeration_issue_priorities: Issue priorities
1188 1190 enumeration_doc_categories: Document categories
1189 1191 enumeration_activities: Activities (time tracking)
1190 1192 enumeration_system_activity: System Activity
1191 1193 description_filter: Filter
1192 1194 description_search: Searchfield
1193 1195 description_choose_project: Projects
1194 1196 description_project_scope: Search scope
1195 1197 description_notes: Notes
1196 1198 description_message_content: Message content
1197 1199 description_query_sort_criteria_attribute: Sort attribute
1198 1200 description_query_sort_criteria_direction: Sort direction
1199 1201 description_user_mail_notification: Mail notification settings
1200 1202 description_available_columns: Available Columns
1201 1203 description_selected_columns: Selected Columns
1202 1204 description_all_columns: All Columns
1203 1205 description_issue_category_reassign: Choose issue category
1204 1206 description_wiki_subpages_reassign: Choose new parent page
1205 1207 description_date_range_list: Choose range from list
1206 1208 description_date_range_interval: Choose range by selecting start and end date
1207 1209 description_date_from: Enter start date
1208 1210 description_date_to: Enter end date
1209 1211 text_repository_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed.<br />Once saved, the identifier cannot be changed.'
@@ -1,1229 +1,1231
1 1 # French translations for Ruby on Rails
2 2 # by Christian Lescuyer (christian@flyingcoders.com)
3 3 # contributor: Sebastien Grosjean - ZenCocoon.com
4 4 # contributor: Thibaut Cuvelier - Developpez.com
5 5
6 6 fr:
7 7 direction: ltr
8 8 date:
9 9 formats:
10 10 default: "%d/%m/%Y"
11 11 short: "%e %b"
12 12 long: "%e %B %Y"
13 13 long_ordinal: "%e %B %Y"
14 14 only_day: "%e"
15 15
16 16 day_names: [dimanche, lundi, mardi, mercredi, jeudi, vendredi, samedi]
17 17 abbr_day_names: [dim, lun, mar, mer, jeu, ven, sam]
18 18
19 19 # Don't forget the nil at the beginning; there's no such thing as a 0th month
20 20 month_names: [~, janvier, février, mars, avril, mai, juin, juillet, août, septembre, octobre, novembre, décembre]
21 21 abbr_month_names: [~, jan., fév., mar., avr., mai, juin, juil., août, sept., oct., nov., déc.]
22 22 # Used in date_select and datime_select.
23 23 order:
24 24 - :day
25 25 - :month
26 26 - :year
27 27
28 28 time:
29 29 formats:
30 30 default: "%d/%m/%Y %H:%M"
31 31 time: "%H:%M"
32 32 short: "%d %b %H:%M"
33 33 long: "%A %d %B %Y %H:%M:%S %Z"
34 34 long_ordinal: "%A %d %B %Y %H:%M:%S %Z"
35 35 only_second: "%S"
36 36 am: 'am'
37 37 pm: 'pm'
38 38
39 39 datetime:
40 40 distance_in_words:
41 41 half_a_minute: "30 secondes"
42 42 less_than_x_seconds:
43 43 zero: "moins d'une seconde"
44 44 one: "moins d'une seconde"
45 45 other: "moins de %{count} secondes"
46 46 x_seconds:
47 47 one: "1 seconde"
48 48 other: "%{count} secondes"
49 49 less_than_x_minutes:
50 50 zero: "moins d'une minute"
51 51 one: "moins d'une minute"
52 52 other: "moins de %{count} minutes"
53 53 x_minutes:
54 54 one: "1 minute"
55 55 other: "%{count} minutes"
56 56 about_x_hours:
57 57 one: "environ une heure"
58 58 other: "environ %{count} heures"
59 59 x_hours:
60 60 one: "une heure"
61 61 other: "%{count} heures"
62 62 x_days:
63 63 one: "un jour"
64 64 other: "%{count} jours"
65 65 about_x_months:
66 66 one: "environ un mois"
67 67 other: "environ %{count} mois"
68 68 x_months:
69 69 one: "un mois"
70 70 other: "%{count} mois"
71 71 about_x_years:
72 72 one: "environ un an"
73 73 other: "environ %{count} ans"
74 74 over_x_years:
75 75 one: "plus d'un an"
76 76 other: "plus de %{count} ans"
77 77 almost_x_years:
78 78 one: "presqu'un an"
79 79 other: "presque %{count} ans"
80 80 prompts:
81 81 year: "Année"
82 82 month: "Mois"
83 83 day: "Jour"
84 84 hour: "Heure"
85 85 minute: "Minute"
86 86 second: "Seconde"
87 87
88 88 number:
89 89 format:
90 90 precision: 3
91 91 separator: ','
92 92 delimiter: ' '
93 93 currency:
94 94 format:
95 95 unit: '€'
96 96 precision: 2
97 97 format: '%n %u'
98 98 human:
99 99 format:
100 100 precision: 3
101 101 storage_units:
102 102 format: "%n %u"
103 103 units:
104 104 byte:
105 105 one: "octet"
106 106 other: "octets"
107 107 kb: "ko"
108 108 mb: "Mo"
109 109 gb: "Go"
110 110 tb: "To"
111 111
112 112 support:
113 113 array:
114 114 sentence_connector: 'et'
115 115 skip_last_comma: true
116 116 word_connector: ", "
117 117 two_words_connector: " et "
118 118 last_word_connector: " et "
119 119
120 120 activerecord:
121 121 errors:
122 122 template:
123 123 header:
124 124 one: "Impossible d'enregistrer %{model} : une erreur"
125 125 other: "Impossible d'enregistrer %{model} : %{count} erreurs."
126 126 body: "Veuillez vérifier les champs suivants :"
127 127 messages:
128 128 inclusion: "n'est pas inclus(e) dans la liste"
129 129 exclusion: "n'est pas disponible"
130 130 invalid: "n'est pas valide"
131 131 confirmation: "ne concorde pas avec la confirmation"
132 132 accepted: "doit être accepté(e)"
133 133 empty: "doit être renseigné(e)"
134 134 blank: "doit être renseigné(e)"
135 135 too_long: "est trop long (pas plus de %{count} caractères)"
136 136 too_short: "est trop court (au moins %{count} caractères)"
137 137 wrong_length: "ne fait pas la bonne longueur (doit comporter %{count} caractères)"
138 138 taken: "est déjà utilisé"
139 139 not_a_number: "n'est pas un nombre"
140 140 not_a_date: "n'est pas une date valide"
141 141 greater_than: "doit être supérieur à %{count}"
142 142 greater_than_or_equal_to: "doit être supérieur ou égal à %{count}"
143 143 equal_to: "doit être égal à %{count}"
144 144 less_than: "doit être inférieur à %{count}"
145 145 less_than_or_equal_to: "doit être inférieur ou égal à %{count}"
146 146 odd: "doit être impair"
147 147 even: "doit être pair"
148 148 greater_than_start_date: "doit être postérieure à la date de début"
149 149 not_same_project: "n'appartient pas au même projet"
150 150 circular_dependency: "Cette relation créerait une dépendance circulaire"
151 151 cant_link_an_issue_with_a_descendant: "Une demande ne peut pas être liée à l'une de ses sous-tâches"
152 152 earlier_than_minimum_start_date: "ne peut pas être antérieure au %{date} à cause des demandes qui précèdent"
153 153 not_a_regexp: "n'est pas une expression regulière valide"
154 154 open_issue_with_closed_parent: "Une demande ouverte ne peut pas être rattachée à une demande fermée"
155 155
156 156 actionview_instancetag_blank_option: Choisir
157 157
158 158 general_text_No: 'Non'
159 159 general_text_Yes: 'Oui'
160 160 general_text_no: 'non'
161 161 general_text_yes: 'oui'
162 162 general_lang_name: 'French (Français)'
163 163 general_csv_separator: ';'
164 164 general_csv_decimal_separator: ','
165 165 general_csv_encoding: ISO-8859-1
166 166 general_pdf_fontname: freesans
167 167 general_pdf_monospaced_fontname: freemono
168 168 general_first_day_of_week: '1'
169 169
170 170 notice_account_updated: Le compte a été mis à jour avec succès.
171 171 notice_account_invalid_credentials: Identifiant ou mot de passe invalide.
172 172 notice_account_password_updated: Mot de passe mis à jour avec succès.
173 173 notice_account_wrong_password: Mot de passe incorrect
174 174 notice_account_register_done: Un message contenant les instructions pour activer votre compte vous a été envoyé à l'adresse %{email}.
175 175 notice_account_unknown_email: Aucun compte ne correspond à cette adresse.
176 176 notice_account_not_activated_yet: Vous n'avez pas encore activé votre compte. Si vous voulez recevoir un nouveau message d'activation, veuillez <a href="%{url}">cliquer sur ce lien</a>.
177 177 notice_account_locked: Votre compte est verrouillé.
178 178 notice_can_t_change_password: Ce compte utilise une authentification externe. Impossible de changer le mot de passe.
179 179 notice_account_lost_email_sent: Un message contenant les instructions pour choisir un nouveau mot de passe vous a été envoyé.
180 180 notice_account_activated: Votre compte a été activé. Vous pouvez à présent vous connecter.
181 181 notice_successful_create: Création effectuée avec succès.
182 182 notice_successful_update: Mise à jour effectuée avec succès.
183 183 notice_successful_delete: Suppression effectuée avec succès.
184 184 notice_successful_connection: Connexion réussie.
185 185 notice_file_not_found: "La page à laquelle vous souhaitez accéder n'existe pas ou a été supprimée."
186 186 notice_locking_conflict: Les données ont été mises à jour par un autre utilisateur. Mise à jour impossible.
187 187 notice_not_authorized: "Vous n'êtes pas autorisé à accéder à cette page."
188 188 notice_not_authorized_archived_project: Le projet auquel vous tentez d'accéder a été archivé.
189 189 notice_email_sent: "Un email a été envoyé à %{value}"
190 190 notice_email_error: "Erreur lors de l'envoi de l'email (%{value})"
191 191 notice_feeds_access_key_reseted: "Votre clé d'accès aux flux Atom a été réinitialisée."
192 192 notice_api_access_key_reseted: Votre clé d'accès API a été réinitialisée.
193 193 notice_failed_to_save_issues: "%{count} demande(s) sur les %{total} sélectionnées n'ont pas pu être mise(s) à jour : %{ids}."
194 194 notice_failed_to_save_time_entries: "%{count} temps passé(s) sur les %{total} sélectionnés n'ont pas pu être mis à jour: %{ids}."
195 195 notice_failed_to_save_members: "Erreur lors de la sauvegarde des membres: %{errors}."
196 196 notice_no_issue_selected: "Aucune demande sélectionnée ! Cochez les demandes que vous voulez mettre à jour."
197 197 notice_account_pending: "Votre compte a été créé et attend l'approbation de l'administrateur."
198 198 notice_default_data_loaded: Paramétrage par défaut chargé avec succès.
199 199 notice_unable_delete_version: Impossible de supprimer cette version.
200 200 notice_unable_delete_time_entry: Impossible de supprimer le temps passé.
201 201 notice_issue_done_ratios_updated: L'avancement des demandes a été mis à jour.
202 202 notice_gantt_chart_truncated: "Le diagramme a été tronqué car il excède le nombre maximal d'éléments pouvant être affichés (%{max})"
203 203 notice_issue_successful_create: "Demande %{id} créée."
204 204 notice_issue_update_conflict: "La demande a été mise à jour par un autre utilisateur pendant que vous la modifiez."
205 205 notice_account_deleted: "Votre compte a été définitivement supprimé."
206 206 notice_user_successful_create: "Utilisateur %{id} créé."
207 207 notice_new_password_must_be_different: Votre nouveau mot de passe doit être différent de votre mot de passe actuel
208 208 notice_import_finished: "%{count} éléments ont été importé(s)"
209 209 notice_import_finished_with_errors: "%{count} élément(s) sur %{total} n'ont pas pu être importé(s)"
210 210
211 211 error_can_t_load_default_data: "Une erreur s'est produite lors du chargement du paramétrage : %{value}"
212 212 error_scm_not_found: "L'entrée et/ou la révision demandée n'existe pas dans le dépôt."
213 213 error_scm_command_failed: "Une erreur s'est produite lors de l'accès au dépôt : %{value}"
214 214 error_scm_annotate: "L'entrée n'existe pas ou ne peut pas être annotée."
215 215 error_scm_annotate_big_text_file: Cette entrée ne peut pas être annotée car elle excède la taille maximale.
216 216 error_issue_not_found_in_project: "La demande n'existe pas ou n'appartient pas à ce projet"
217 217 error_no_tracker_in_project: "Aucun tracker n'est associé à ce projet. Vérifier la configuration du projet."
218 218 error_no_default_issue_status: "Aucun statut de demande n'est défini par défaut. Vérifier votre configuration (Administration -> Statuts de demandes)."
219 219 error_can_not_delete_custom_field: Impossible de supprimer le champ personnalisé
220 220 error_can_not_delete_tracker: Ce tracker contient des demandes et ne peut pas être supprimé.
221 221 error_can_not_remove_role: Ce rôle est utilisé et ne peut pas être supprimé.
222 222 error_can_not_reopen_issue_on_closed_version: 'Une demande assignée à une version fermée ne peut pas être réouverte'
223 223 error_can_not_archive_project: "Ce projet ne peut pas être archivé"
224 224 error_issue_done_ratios_not_updated: L'avancement des demandes n'a pas pu être mis à jour.
225 225 error_workflow_copy_source: 'Veuillez sélectionner un tracker et/ou un rôle source'
226 226 error_workflow_copy_target: 'Veuillez sélectionner les trackers et rôles cibles'
227 227 error_unable_delete_issue_status: Impossible de supprimer le statut de demande
228 228 error_unable_to_connect: Connexion impossible (%{value})
229 229 error_attachment_too_big: Ce fichier ne peut pas être attaché car il excède la taille maximale autorisée (%{max_size})
230 230 error_session_expired: "Votre session a expiré. Veuillez vous reconnecter."
231 231 warning_attachments_not_saved: "%{count} fichier(s) n'ont pas pu être sauvegardés."
232 232 error_password_expired: "Votre mot de passe a expiré ou nécessite d'être changé."
233 233 error_invalid_file_encoding: "Le fichier n'est pas un fichier %{encoding} valide"
234 234 error_invalid_csv_file_or_settings: "Le fichier n'est pas un fichier CSV ou n'est pas conforme aux paramètres sélectionnés"
235 235 error_can_not_read_import_file: "Une erreur est survenue lors de la lecture du fichier à importer"
236 236 error_attachment_extension_not_allowed: "L'extension %{extension} n'est pas autorisée"
237 237 error_ldap_bind_credentials: "Identifiant ou mot de passe LDAP incorrect"
238 238 error_no_tracker_allowed_for_new_issue_in_project: "Le projet ne dispose d'aucun tracker sur lequel vous pouvez créer une demande"
239 239 error_no_projects_with_tracker_allowed_for_new_issue: "Aucun projet ne dispose d'un tracker sur lequel vous pouvez créer une demande"
240 240 error_move_of_child_not_possible: "La sous-tâche %{child} n'a pas pu être déplacée dans le nouveau projet : %{errors}"
241 241 error_cannot_reassign_time_entries_to_an_issue_about_to_be_deleted: "Le temps passé ne peut pas être réaffecté à une demande qui va être supprimée"
242 242 warning_fields_cleared_on_bulk_edit: "Les changements apportés entraîneront la suppression automatique des valeurs d'un ou plusieurs champs sur les objets sélectionnés"
243 243
244 244 mail_subject_lost_password: "Votre mot de passe %{value}"
245 245 mail_body_lost_password: 'Pour changer votre mot de passe, cliquez sur le lien suivant :'
246 246 mail_subject_register: "Activation de votre compte %{value}"
247 247 mail_body_register: 'Pour activer votre compte, cliquez sur le lien suivant :'
248 248 mail_body_account_information_external: "Vous pouvez utiliser votre compte %{value} pour vous connecter."
249 249 mail_body_account_information: Paramètres de connexion de votre compte
250 250 mail_subject_account_activation_request: "Demande d'activation d'un compte %{value}"
251 251 mail_body_account_activation_request: "Un nouvel utilisateur (%{value}) s'est inscrit. Son compte nécessite votre approbation :"
252 252 mail_subject_reminder: "%{count} demande(s) arrivent à échéance (%{days})"
253 253 mail_body_reminder: "%{count} demande(s) qui vous sont assignées arrivent à échéance dans les %{days} prochains jours :"
254 254 mail_subject_wiki_content_added: "Page wiki '%{id}' ajoutée"
255 255 mail_body_wiki_content_added: "La page wiki '%{id}' a été ajoutée par %{author}."
256 256 mail_subject_wiki_content_updated: "Page wiki '%{id}' mise à jour"
257 257 mail_body_wiki_content_updated: "La page wiki '%{id}' a été mise à jour par %{author}."
258 258 mail_body_settings_updated: "Les paramètres suivants ont été modifiés :"
259 259 mail_body_password_updated: "Votre mot de passe a été changé."
260 260
261 261 field_name: Nom
262 262 field_description: Description
263 263 field_summary: Résumé
264 264 field_is_required: Obligatoire
265 265 field_firstname: Prénom
266 266 field_lastname: Nom
267 267 field_mail: Email
268 268 field_address: Email
269 269 field_filename: Fichier
270 270 field_filesize: Taille
271 271 field_downloads: Téléchargements
272 272 field_author: Auteur
273 273 field_created_on: Créé
274 274 field_updated_on: Mis-à-jour
275 275 field_closed_on: Fermé
276 276 field_field_format: Format
277 277 field_is_for_all: Pour tous les projets
278 278 field_possible_values: Valeurs possibles
279 279 field_regexp: Expression régulière
280 280 field_min_length: Longueur minimum
281 281 field_max_length: Longueur maximum
282 282 field_value: Valeur
283 283 field_category: Catégorie
284 284 field_title: Titre
285 285 field_project: Projet
286 286 field_issue: Demande
287 287 field_status: Statut
288 288 field_notes: Notes
289 289 field_is_closed: Demande fermée
290 290 field_is_default: Valeur par défaut
291 291 field_tracker: Tracker
292 292 field_subject: Sujet
293 293 field_due_date: Echéance
294 294 field_assigned_to: Assigné à
295 295 field_priority: Priorité
296 296 field_fixed_version: Version cible
297 297 field_user: Utilisateur
298 298 field_principal: Principal
299 299 field_role: Rôle
300 300 field_homepage: Site web
301 301 field_is_public: Public
302 302 field_parent: Sous-projet de
303 303 field_is_in_roadmap: Demandes affichées dans la roadmap
304 304 field_login: Identifiant
305 305 field_mail_notification: Notifications par mail
306 306 field_admin: Administrateur
307 307 field_last_login_on: Dernière connexion
308 308 field_language: Langue
309 309 field_effective_date: Date
310 310 field_password: Mot de passe
311 311 field_new_password: Nouveau mot de passe
312 312 field_password_confirmation: Confirmation
313 313 field_version: Version
314 314 field_type: Type
315 315 field_host: Hôte
316 316 field_port: Port
317 317 field_account: Compte
318 318 field_base_dn: Base DN
319 319 field_attr_login: Attribut Identifiant
320 320 field_attr_firstname: Attribut Prénom
321 321 field_attr_lastname: Attribut Nom
322 322 field_attr_mail: Attribut Email
323 323 field_onthefly: Création des utilisateurs à la volée
324 324 field_start_date: Début
325 325 field_done_ratio: "% réalisé"
326 326 field_auth_source: Mode d'authentification
327 327 field_hide_mail: Cacher mon adresse mail
328 328 field_comments: Commentaire
329 329 field_url: URL
330 330 field_start_page: Page de démarrage
331 331 field_subproject: Sous-projet
332 332 field_hours: Heures
333 333 field_activity: Activité
334 334 field_spent_on: Date
335 335 field_identifier: Identifiant
336 336 field_is_filter: Utilisé comme filtre
337 337 field_issue_to: Demande liée
338 338 field_delay: Retard
339 339 field_assignable: Demandes assignables à ce rôle
340 340 field_redirect_existing_links: Rediriger les liens existants
341 341 field_estimated_hours: Temps estimé
342 342 field_column_names: Colonnes
343 343 field_time_entries: Temps passé
344 344 field_time_zone: Fuseau horaire
345 345 field_searchable: Utilisé pour les recherches
346 346 field_default_value: Valeur par défaut
347 347 field_comments_sorting: Afficher les commentaires
348 348 field_parent_title: Page parent
349 349 field_editable: Modifiable
350 350 field_watcher: Observateur
351 351 field_identity_url: URL OpenID
352 352 field_content: Contenu
353 353 field_group_by: Grouper par
354 354 field_sharing: Partage
355 355 field_parent_issue: Tâche parente
356 356 field_member_of_group: Groupe de l'assigné
357 357 field_assigned_to_role: Rôle de l'assigné
358 358 field_text: Champ texte
359 359 field_visible: Visible
360 360 field_warn_on_leaving_unsaved: "M'avertir lorsque je quitte une page contenant du texte non sauvegardé"
361 361 field_issues_visibility: Visibilité des demandes
362 362 field_is_private: Privée
363 363 field_commit_logs_encoding: Encodage des messages de commit
364 364 field_scm_path_encoding: Encodage des chemins
365 365 field_path_to_repository: Chemin du dépôt
366 366 field_root_directory: Répertoire racine
367 367 field_cvsroot: CVSROOT
368 368 field_cvs_module: Module
369 369 field_repository_is_default: Dépôt principal
370 370 field_multiple: Valeurs multiples
371 371 field_auth_source_ldap_filter: Filtre LDAP
372 372 field_core_fields: Champs standards
373 373 field_timeout: "Timeout (en secondes)"
374 374 field_board_parent: Forum parent
375 375 field_private_notes: Notes privées
376 376 field_inherit_members: Hériter les membres
377 377 field_generate_password: Générer un mot de passe
378 378 field_must_change_passwd: Doit changer de mot de passe à la prochaine connexion
379 379 field_default_status: Statut par défaut
380 380 field_users_visibility: Visibilité des utilisateurs
381 381 field_time_entries_visibility: Visibilité du temps passé
382 382 field_total_estimated_hours: Temps estimé total
383 383 field_default_version: Version par défaut
384 384 field_textarea_font: Police utilisée pour les champs texte
385 field_updated_by: Mise à jour par
386 field_last_updated_by: Dernière mise à jour par
385 387
386 388 setting_app_title: Titre de l'application
387 389 setting_app_subtitle: Sous-titre de l'application
388 390 setting_welcome_text: Texte d'accueil
389 391 setting_default_language: Langue par défaut
390 392 setting_login_required: Authentification obligatoire
391 393 setting_self_registration: Inscription des nouveaux utilisateurs
392 394 setting_attachment_max_size: Taille maximale des fichiers
393 395 setting_issues_export_limit: Limite d'exportation des demandes
394 396 setting_mail_from: Adresse d'émission
395 397 setting_bcc_recipients: Destinataires en copie cachée (cci)
396 398 setting_plain_text_mail: Mail en texte brut (non HTML)
397 399 setting_host_name: Nom d'hôte et chemin
398 400 setting_text_formatting: Formatage du texte
399 401 setting_wiki_compression: Compression de l'historique des pages wiki
400 402 setting_feeds_limit: Nombre maximal d'éléments dans les flux Atom
401 403 setting_default_projects_public: Définir les nouveaux projets comme publics par défaut
402 404 setting_autofetch_changesets: Récupération automatique des commits
403 405 setting_sys_api_enabled: Activer les WS pour la gestion des dépôts
404 406 setting_commit_ref_keywords: Mots-clés de référencement
405 407 setting_commit_fix_keywords: Mots-clés de résolution
406 408 setting_autologin: Durée maximale de connexion automatique
407 409 setting_date_format: Format de date
408 410 setting_time_format: Format d'heure
409 411 setting_timespan_format: Format des temps en heures
410 412 setting_cross_project_issue_relations: Autoriser les relations entre demandes de différents projets
411 413 setting_cross_project_subtasks: Autoriser les sous-tâches dans des projets différents
412 414 setting_issue_list_default_columns: Colonnes affichées par défaut sur la liste des demandes
413 415 setting_repositories_encodings: Encodages des fichiers et des dépôts
414 416 setting_emails_header: En-tête des emails
415 417 setting_emails_footer: Pied-de-page des emails
416 418 setting_protocol: Protocole
417 419 setting_per_page_options: Options d'objets affichés par page
418 420 setting_user_format: Format d'affichage des utilisateurs
419 421 setting_activity_days_default: Nombre de jours affichés sur l'activité des projets
420 422 setting_display_subprojects_issues: Afficher par défaut les demandes des sous-projets sur les projets principaux
421 423 setting_enabled_scm: SCM activés
422 424 setting_mail_handler_body_delimiters: "Tronquer les emails après l'une de ces lignes"
423 425 setting_mail_handler_enable_regex_delimiters: "Utiliser les expressions regulières"
424 426 setting_mail_handler_api_enabled: "Activer le WS pour la réception d'emails"
425 427 setting_mail_handler_api_key: Clé de protection de l'API
426 428 setting_sequential_project_identifiers: Générer des identifiants de projet séquentiels
427 429 setting_gravatar_enabled: Afficher les Gravatar des utilisateurs
428 430 setting_gravatar_default: Image Gravatar par défaut
429 431 setting_diff_max_lines_displayed: Nombre maximum de lignes de diff affichées
430 432 setting_file_max_size_displayed: Taille maximum des fichiers texte affichés en ligne
431 433 setting_repository_log_display_limit: "Nombre maximum de révisions affichées sur l'historique d'un fichier"
432 434 setting_openid: "Autoriser l'authentification et l'enregistrement OpenID"
433 435 setting_password_max_age: Expiration des mots de passe après
434 436 setting_password_min_length: Longueur minimum des mots de passe
435 437 setting_new_project_user_role_id: Rôle donné à un utilisateur non-administrateur qui crée un projet
436 438 setting_default_projects_modules: Modules activés par défaut pour les nouveaux projets
437 439 setting_issue_done_ratio: Calcul de l'avancement des demandes
438 440 setting_issue_done_ratio_issue_field: 'Utiliser le champ % effectué'
439 441 setting_issue_done_ratio_issue_status: Utiliser le statut
440 442 setting_start_of_week: Jour de début des calendriers
441 443 setting_rest_api_enabled: Activer l'API REST
442 444 setting_cache_formatted_text: Mettre en cache le texte formaté
443 445 setting_default_notification_option: Option de notification par défaut
444 446 setting_commit_logtime_enabled: Permettre la saisie de temps
445 447 setting_commit_logtime_activity_id: Activité pour le temps saisi
446 448 setting_gantt_items_limit: Nombre maximum d'éléments affichés sur le gantt
447 449 setting_issue_group_assignment: Permettre l'assignation des demandes aux groupes
448 450 setting_default_issue_start_date_to_creation_date: Donner à la date de début d'une nouvelle demande la valeur de la date du jour
449 451 setting_commit_cross_project_ref: Permettre le référencement et la résolution des demandes de tous les autres projets
450 452 setting_unsubscribe: Permettre aux utilisateurs de supprimer leur propre compte
451 453 setting_session_lifetime: Durée de vie maximale des sessions
452 454 setting_session_timeout: Durée maximale d'inactivité
453 455 setting_thumbnails_enabled: Afficher les vignettes des images
454 456 setting_thumbnails_size: Taille des vignettes (en pixels)
455 457 setting_non_working_week_days: Jours non travaillés
456 458 setting_jsonp_enabled: Activer le support JSONP
457 459 setting_default_projects_tracker_ids: Trackers par défaut pour les nouveaux projets
458 460 setting_mail_handler_excluded_filenames: Exclure les fichiers attachés par leur nom
459 461 setting_force_default_language_for_anonymous: Forcer la langue par défault pour les utilisateurs anonymes
460 462 setting_force_default_language_for_loggedin: Forcer la langue par défault pour les utilisateurs identifiés
461 463 setting_link_copied_issue: Lier les demandes lors de la copie
462 464 setting_max_additional_emails: Nombre maximal d'adresses email additionnelles
463 465 setting_search_results_per_page: Résultats de recherche affichés par page
464 466 setting_attachment_extensions_allowed: Extensions autorisées
465 467 setting_attachment_extensions_denied: Extensions non autorisées
466 468 setting_sys_api_key: Clé de protection de l'API
467 469 setting_lost_password: Autoriser la réinitialisation par email de mot de passe perdu
468 470 setting_new_item_menu_tab: Onglet de création d'objets dans le menu du project
469 471 setting_commit_logs_formatting: Appliquer le formattage de texte aux messages de commit
470 472 setting_timelog_required_fields: Champs obligatoire pour les temps passés
471 473
472 474 permission_add_project: Créer un projet
473 475 permission_add_subprojects: Créer des sous-projets
474 476 permission_edit_project: Modifier le projet
475 477 permission_close_project: Fermer / réouvrir le projet
476 478 permission_select_project_modules: Choisir les modules
477 479 permission_manage_members: Gérer les membres
478 480 permission_manage_project_activities: Gérer les activités
479 481 permission_manage_versions: Gérer les versions
480 482 permission_manage_categories: Gérer les catégories de demandes
481 483 permission_view_issues: Voir les demandes
482 484 permission_add_issues: Créer des demandes
483 485 permission_edit_issues: Modifier les demandes
484 486 permission_copy_issues: Copier les demandes
485 487 permission_manage_issue_relations: Gérer les relations
486 488 permission_set_issues_private: Rendre les demandes publiques ou privées
487 489 permission_set_own_issues_private: Rendre ses propres demandes publiques ou privées
488 490 permission_add_issue_notes: Ajouter des notes
489 491 permission_edit_issue_notes: Modifier les notes
490 492 permission_edit_own_issue_notes: Modifier ses propres notes
491 493 permission_view_private_notes: Voir les notes privées
492 494 permission_set_notes_private: Rendre les notes privées
493 495 permission_move_issues: Déplacer les demandes
494 496 permission_delete_issues: Supprimer les demandes
495 497 permission_manage_public_queries: Gérer les requêtes publiques
496 498 permission_save_queries: Sauvegarder les requêtes
497 499 permission_view_gantt: Voir le gantt
498 500 permission_view_calendar: Voir le calendrier
499 501 permission_view_issue_watchers: Voir la liste des observateurs
500 502 permission_add_issue_watchers: Ajouter des observateurs
501 503 permission_delete_issue_watchers: Supprimer des observateurs
502 504 permission_log_time: Saisir le temps passé
503 505 permission_view_time_entries: Voir le temps passé
504 506 permission_edit_time_entries: Modifier les temps passés
505 507 permission_edit_own_time_entries: Modifier son propre temps passé
506 508 permission_manage_news: Gérer les annonces
507 509 permission_comment_news: Commenter les annonces
508 510 permission_view_documents: Voir les documents
509 511 permission_add_documents: Ajouter des documents
510 512 permission_edit_documents: Modifier les documents
511 513 permission_delete_documents: Supprimer les documents
512 514 permission_manage_files: Gérer les fichiers
513 515 permission_view_files: Voir les fichiers
514 516 permission_manage_wiki: Gérer le wiki
515 517 permission_rename_wiki_pages: Renommer les pages
516 518 permission_delete_wiki_pages: Supprimer les pages
517 519 permission_view_wiki_pages: Voir le wiki
518 520 permission_view_wiki_edits: "Voir l'historique des modifications"
519 521 permission_edit_wiki_pages: Modifier les pages
520 522 permission_delete_wiki_pages_attachments: Supprimer les fichiers joints
521 523 permission_protect_wiki_pages: Protéger les pages
522 524 permission_manage_repository: Gérer le dépôt de sources
523 525 permission_browse_repository: Parcourir les sources
524 526 permission_view_changesets: Voir les révisions
525 527 permission_commit_access: Droit de commit
526 528 permission_manage_boards: Gérer les forums
527 529 permission_view_messages: Voir les messages
528 530 permission_add_messages: Poster un message
529 531 permission_edit_messages: Modifier les messages
530 532 permission_edit_own_messages: Modifier ses propres messages
531 533 permission_delete_messages: Supprimer les messages
532 534 permission_delete_own_messages: Supprimer ses propres messages
533 535 permission_export_wiki_pages: Exporter les pages
534 536 permission_manage_subtasks: Gérer les sous-tâches
535 537 permission_manage_related_issues: Gérer les demandes associées
536 538 permission_import_issues: Importer des demandes
537 539
538 540 project_module_issue_tracking: Suivi des demandes
539 541 project_module_time_tracking: Suivi du temps passé
540 542 project_module_news: Publication d'annonces
541 543 project_module_documents: Publication de documents
542 544 project_module_files: Publication de fichiers
543 545 project_module_wiki: Wiki
544 546 project_module_repository: Dépôt de sources
545 547 project_module_boards: Forums de discussion
546 548 project_module_calendar: Calendrier
547 549 project_module_gantt: Gantt
548 550
549 551 label_user: Utilisateur
550 552 label_user_plural: Utilisateurs
551 553 label_user_new: Nouvel utilisateur
552 554 label_user_anonymous: Anonyme
553 555 label_project: Projet
554 556 label_project_new: Nouveau projet
555 557 label_project_plural: Projets
556 558 label_x_projects:
557 559 zero: aucun projet
558 560 one: un projet
559 561 other: "%{count} projets"
560 562 label_project_all: Tous les projets
561 563 label_project_latest: Derniers projets
562 564 label_issue: Demande
563 565 label_issue_new: Nouvelle demande
564 566 label_issue_plural: Demandes
565 567 label_issue_view_all: Voir toutes les demandes
566 568 label_issues_by: "Demandes par %{value}"
567 569 label_issue_added: Demande ajoutée
568 570 label_issue_updated: Demande mise à jour
569 571 label_issue_note_added: Note ajoutée
570 572 label_issue_status_updated: Statut changé
571 573 label_issue_assigned_to_updated: Assigné changé
572 574 label_issue_priority_updated: Priorité changée
573 575 label_document: Document
574 576 label_document_new: Nouveau document
575 577 label_document_plural: Documents
576 578 label_document_added: Document ajouté
577 579 label_role: Rôle
578 580 label_role_plural: Rôles
579 581 label_role_new: Nouveau rôle
580 582 label_role_and_permissions: Rôles et permissions
581 583 label_role_anonymous: Anonyme
582 584 label_role_non_member: Non membre
583 585 label_member: Membre
584 586 label_member_new: Nouveau membre
585 587 label_member_plural: Membres
586 588 label_tracker: Tracker
587 589 label_tracker_plural: Trackers
588 590 label_tracker_all: Tous les trackers
589 591 label_tracker_new: Nouveau tracker
590 592 label_workflow: Workflow
591 593 label_issue_status: Statut de demandes
592 594 label_issue_status_plural: Statuts de demandes
593 595 label_issue_status_new: Nouveau statut
594 596 label_issue_category: Catégorie de demandes
595 597 label_issue_category_plural: Catégories de demandes
596 598 label_issue_category_new: Nouvelle catégorie
597 599 label_custom_field: Champ personnalisé
598 600 label_custom_field_plural: Champs personnalisés
599 601 label_custom_field_new: Nouveau champ personnalisé
600 602 label_enumerations: Listes de valeurs
601 603 label_enumeration_new: Nouvelle valeur
602 604 label_information: Information
603 605 label_information_plural: Informations
604 606 label_please_login: Identification
605 607 label_register: S'enregistrer
606 608 label_login_with_open_id_option: S'authentifier avec OpenID
607 609 label_password_lost: Mot de passe perdu
608 610 label_password_required: Confirmez votre mot de passe pour continuer
609 611 label_home: Accueil
610 612 label_my_page: Ma page
611 613 label_my_account: Mon compte
612 614 label_my_projects: Mes projets
613 615 label_my_page_block: Blocs disponibles
614 616 label_administration: Administration
615 617 label_login: Connexion
616 618 label_logout: Déconnexion
617 619 label_help: Aide
618 620 label_reported_issues: Demandes soumises
619 621 label_assigned_issues: Demandes assignées
620 622 label_assigned_to_me_issues: Demandes qui me sont assignées
621 623 label_last_login: Dernière connexion
622 624 label_registered_on: Inscrit le
623 625 label_activity: Activité
624 626 label_overall_activity: Activité globale
625 627 label_user_activity: "Activité de %{value}"
626 628 label_new: Nouveau
627 629 label_logged_as: Connecté en tant que
628 630 label_environment: Environnement
629 631 label_authentication: Authentification
630 632 label_auth_source: Mode d'authentification
631 633 label_auth_source_new: Nouveau mode d'authentification
632 634 label_auth_source_plural: Modes d'authentification
633 635 label_subproject_plural: Sous-projets
634 636 label_subproject_new: Nouveau sous-projet
635 637 label_and_its_subprojects: "%{value} et ses sous-projets"
636 638 label_min_max_length: Longueurs mini - maxi
637 639 label_list: Liste
638 640 label_date: Date
639 641 label_integer: Entier
640 642 label_float: Nombre décimal
641 643 label_boolean: Booléen
642 644 label_string: Texte
643 645 label_text: Texte long
644 646 label_attribute: Attribut
645 647 label_attribute_plural: Attributs
646 648 label_no_data: Aucune donnée à afficher
647 649 label_change_status: Changer le statut
648 650 label_history: Historique
649 651 label_attachment: Fichier
650 652 label_attachment_new: Nouveau fichier
651 653 label_attachment_delete: Supprimer le fichier
652 654 label_attachment_plural: Fichiers
653 655 label_file_added: Fichier ajouté
654 656 label_report: Rapport
655 657 label_report_plural: Rapports
656 658 label_news: Annonce
657 659 label_news_new: Nouvelle annonce
658 660 label_news_plural: Annonces
659 661 label_news_latest: Dernières annonces
660 662 label_news_view_all: Voir toutes les annonces
661 663 label_news_added: Annonce ajoutée
662 664 label_news_comment_added: Commentaire ajouté à une annonce
663 665 label_settings: Configuration
664 666 label_overview: Aperçu
665 667 label_version: Version
666 668 label_version_new: Nouvelle version
667 669 label_version_plural: Versions
668 670 label_close_versions: Fermer les versions terminées
669 671 label_confirmation: Confirmation
670 672 label_export_to: 'Formats disponibles :'
671 673 label_read: Lire...
672 674 label_public_projects: Projets publics
673 675 label_open_issues: ouvert
674 676 label_open_issues_plural: ouverts
675 677 label_closed_issues: fermé
676 678 label_closed_issues_plural: fermés
677 679 label_x_open_issues_abbr:
678 680 zero: 0 ouverte
679 681 one: 1 ouverte
680 682 other: "%{count} ouvertes"
681 683 label_x_closed_issues_abbr:
682 684 zero: 0 fermée
683 685 one: 1 fermée
684 686 other: "%{count} fermées"
685 687 label_x_issues:
686 688 zero: 0 demande
687 689 one: 1 demande
688 690 other: "%{count} demandes"
689 691 label_total: Total
690 692 label_total_plural: Totaux
691 693 label_total_time: Temps total
692 694 label_permissions: Permissions
693 695 label_current_status: Statut actuel
694 696 label_new_statuses_allowed: Nouveaux statuts autorisés
695 697 label_all: tous
696 698 label_any: tous
697 699 label_none: aucun
698 700 label_nobody: personne
699 701 label_next: Suivant
700 702 label_previous: Précédent
701 703 label_used_by: Utilisé par
702 704 label_details: Détails
703 705 label_add_note: Ajouter une note
704 706 label_calendar: Calendrier
705 707 label_months_from: mois depuis
706 708 label_gantt: Gantt
707 709 label_internal: Interne
708 710 label_last_changes: "%{count} derniers changements"
709 711 label_change_view_all: Voir tous les changements
710 712 label_personalize_page: Personnaliser cette page
711 713 label_comment: Commentaire
712 714 label_comment_plural: Commentaires
713 715 label_x_comments:
714 716 zero: aucun commentaire
715 717 one: un commentaire
716 718 other: "%{count} commentaires"
717 719 label_comment_add: Ajouter un commentaire
718 720 label_comment_added: Commentaire ajouté
719 721 label_comment_delete: Supprimer les commentaires
720 722 label_query: Rapport personnalisé
721 723 label_query_plural: Rapports personnalisés
722 724 label_query_new: Nouveau rapport
723 725 label_my_queries: Mes rapports personnalisés
724 726 label_filter_add: Ajouter le filtre
725 727 label_filter_plural: Filtres
726 728 label_equals: égal
727 729 label_not_equals: différent
728 730 label_in_less_than: dans moins de
729 731 label_in_more_than: dans plus de
730 732 label_in_the_next_days: dans les prochains jours
731 733 label_in_the_past_days: dans les derniers jours
732 734 label_greater_or_equal: '>='
733 735 label_less_or_equal: '<='
734 736 label_between: entre
735 737 label_in: dans
736 738 label_today: aujourd'hui
737 739 label_all_time: toute la période
738 740 label_yesterday: hier
739 741 label_this_week: cette semaine
740 742 label_last_week: la semaine dernière
741 743 label_last_n_weeks: "les %{count} dernières semaines"
742 744 label_last_n_days: "les %{count} derniers jours"
743 745 label_this_month: ce mois-ci
744 746 label_last_month: le mois dernier
745 747 label_this_year: cette année
746 748 label_date_range: Période
747 749 label_less_than_ago: il y a moins de
748 750 label_more_than_ago: il y a plus de
749 751 label_ago: il y a
750 752 label_contains: contient
751 753 label_not_contains: ne contient pas
752 754 label_any_issues_in_project: une demande du projet
753 755 label_any_issues_not_in_project: une demande hors du projet
754 756 label_no_issues_in_project: aucune demande du projet
755 757 label_any_open_issues: une demande ouverte
756 758 label_no_open_issues: aucune demande ouverte
757 759 label_day_plural: jours
758 760 label_repository: Dépôt
759 761 label_repository_new: Nouveau dépôt
760 762 label_repository_plural: Dépôts
761 763 label_browse: Parcourir
762 764 label_branch: Branche
763 765 label_tag: Tag
764 766 label_revision: Révision
765 767 label_revision_plural: Révisions
766 768 label_revision_id: "Révision %{value}"
767 769 label_associated_revisions: Révisions associées
768 770 label_added: ajouté
769 771 label_modified: modifié
770 772 label_copied: copié
771 773 label_renamed: renommé
772 774 label_deleted: supprimé
773 775 label_latest_revision: Dernière révision
774 776 label_latest_revision_plural: Dernières révisions
775 777 label_view_revisions: Voir les révisions
776 778 label_view_all_revisions: Voir toutes les révisions
777 779 label_max_size: Taille maximale
778 780 label_sort_highest: Remonter en premier
779 781 label_sort_higher: Remonter
780 782 label_sort_lower: Descendre
781 783 label_sort_lowest: Descendre en dernier
782 784 label_roadmap: Roadmap
783 785 label_roadmap_due_in: "Échéance dans %{value}"
784 786 label_roadmap_overdue: "En retard de %{value}"
785 787 label_roadmap_no_issues: Aucune demande pour cette version
786 788 label_search: Recherche
787 789 label_result_plural: Résultats
788 790 label_all_words: Tous les mots
789 791 label_wiki: Wiki
790 792 label_wiki_edit: Révision wiki
791 793 label_wiki_edit_plural: Révisions wiki
792 794 label_wiki_page: Page wiki
793 795 label_wiki_page_plural: Pages wiki
794 796 label_wiki_page_new: Nouvelle page wiki
795 797 label_index_by_title: Index par titre
796 798 label_index_by_date: Index par date
797 799 label_current_version: Version actuelle
798 800 label_preview: Prévisualisation
799 801 label_feed_plural: Flux Atom
800 802 label_changes_details: Détails de tous les changements
801 803 label_issue_tracking: Suivi des demandes
802 804 label_spent_time: Temps passé
803 805 label_total_spent_time: Temps passé total
804 806 label_overall_spent_time: Temps passé global
805 807 label_f_hour: "%{value} heure"
806 808 label_f_hour_plural: "%{value} heures"
807 809 label_f_hour_short: "%{value} h"
808 810 label_time_tracking: Suivi du temps
809 811 label_change_plural: Changements
810 812 label_statistics: Statistiques
811 813 label_commits_per_month: Commits par mois
812 814 label_commits_per_author: Commits par auteur
813 815 label_diff: diff
814 816 label_view_diff: Voir les différences
815 817 label_diff_inline: en ligne
816 818 label_diff_side_by_side: côte à côte
817 819 label_options: Options
818 820 label_copy_workflow_from: Copier le workflow de
819 821 label_permissions_report: Synthèse des permissions
820 822 label_watched_issues: Demandes surveillées
821 823 label_related_issues: Demandes liées
822 824 label_applied_status: Statut appliqué
823 825 label_loading: Chargement...
824 826 label_relation_new: Nouvelle relation
825 827 label_relation_delete: Supprimer la relation
826 828 label_relates_to: Lié à
827 829 label_duplicates: Duplique
828 830 label_duplicated_by: Dupliqué par
829 831 label_blocks: Bloque
830 832 label_blocked_by: Bloqué par
831 833 label_precedes: Précède
832 834 label_follows: Suit
833 835 label_copied_to: Copié vers
834 836 label_copied_from: Copié depuis
835 837 label_stay_logged_in: Rester connecté
836 838 label_disabled: désactivé
837 839 label_show_completed_versions: Voir les versions passées
838 840 label_me: moi
839 841 label_board: Forum
840 842 label_board_new: Nouveau forum
841 843 label_board_plural: Forums
842 844 label_board_locked: Verrouillé
843 845 label_board_sticky: Sticky
844 846 label_topic_plural: Discussions
845 847 label_message_plural: Messages
846 848 label_message_last: Dernier message
847 849 label_message_new: Nouveau message
848 850 label_message_posted: Message ajouté
849 851 label_reply_plural: Réponses
850 852 label_send_information: Envoyer les informations à l'utilisateur
851 853 label_year: Année
852 854 label_month: Mois
853 855 label_week: Semaine
854 856 label_date_from: Du
855 857 label_date_to: Au
856 858 label_language_based: Basé sur la langue de l'utilisateur
857 859 label_sort_by: "Trier par %{value}"
858 860 label_send_test_email: Envoyer un email de test
859 861 label_feeds_access_key: Clé d'accès Atom
860 862 label_missing_feeds_access_key: Clé d'accès Atom manquante
861 863 label_feeds_access_key_created_on: "Clé d'accès Atom créée il y a %{value}"
862 864 label_module_plural: Modules
863 865 label_added_time_by: "Ajouté par %{author} il y a %{age}"
864 866 label_updated_time_by: "Mis à jour par %{author} il y a %{age}"
865 867 label_updated_time: "Mis à jour il y a %{value}"
866 868 label_jump_to_a_project: Aller à un projet...
867 869 label_file_plural: Fichiers
868 870 label_changeset_plural: Révisions
869 871 label_default_columns: Colonnes par défaut
870 872 label_no_change_option: (Pas de changement)
871 873 label_bulk_edit_selected_issues: Modifier les demandes sélectionnées
872 874 label_bulk_edit_selected_time_entries: Modifier les temps passés sélectionnés
873 875 label_theme: Thème
874 876 label_default: Défaut
875 877 label_search_titles_only: Uniquement dans les titres
876 878 label_user_mail_option_all: "Pour tous les événements de tous mes projets"
877 879 label_user_mail_option_selected: "Pour tous les événements des projets sélectionnés..."
878 880 label_user_mail_option_none: Aucune notification
879 881 label_user_mail_option_only_my_events: Seulement pour ce que je surveille
880 882 label_user_mail_no_self_notified: "Je ne veux pas être notifié des changements que j'effectue"
881 883 label_registration_activation_by_email: activation du compte par email
882 884 label_registration_manual_activation: activation manuelle du compte
883 885 label_registration_automatic_activation: activation automatique du compte
884 886 label_display_per_page: "Par page : %{value}"
885 887 label_age: Âge
886 888 label_change_properties: Changer les propriétés
887 889 label_general: Général
888 890 label_more: Plus
889 891 label_scm: SCM
890 892 label_plugins: Plugins
891 893 label_ldap_authentication: Authentification LDAP
892 894 label_downloads_abbr: D/L
893 895 label_optional_description: Description facultative
894 896 label_add_another_file: Ajouter un autre fichier
895 897 label_preferences: Préférences
896 898 label_chronological_order: Dans l'ordre chronologique
897 899 label_reverse_chronological_order: Dans l'ordre chronologique inverse
898 900 label_planning: Planning
899 901 label_incoming_emails: Emails entrants
900 902 label_generate_key: Générer une clé
901 903 label_issue_watchers: Observateurs
902 904 label_example: Exemple
903 905 label_display: Affichage
904 906 label_sort: Tri
905 907 label_ascending: Croissant
906 908 label_descending: Décroissant
907 909 label_date_from_to: Du %{start} au %{end}
908 910 label_wiki_content_added: Page wiki ajoutée
909 911 label_wiki_content_updated: Page wiki mise à jour
910 912 label_group: Groupe
911 913 label_group_plural: Groupes
912 914 label_group_new: Nouveau groupe
913 915 label_group_anonymous: Utilisateurs anonymes
914 916 label_group_non_member: Utilisateurs non membres
915 917 label_time_entry_plural: Temps passé
916 918 label_version_sharing_none: Non partagé
917 919 label_version_sharing_descendants: Avec les sous-projets
918 920 label_version_sharing_hierarchy: Avec toute la hiérarchie
919 921 label_version_sharing_tree: Avec tout l'arbre
920 922 label_version_sharing_system: Avec tous les projets
921 923 label_update_issue_done_ratios: Mettre à jour l'avancement des demandes
922 924 label_copy_source: Source
923 925 label_copy_target: Cible
924 926 label_copy_same_as_target: Comme la cible
925 927 label_display_used_statuses_only: N'afficher que les statuts utilisés dans ce tracker
926 928 label_api_access_key: Clé d'accès API
927 929 label_missing_api_access_key: Clé d'accès API manquante
928 930 label_api_access_key_created_on: Clé d'accès API créée il y a %{value}
929 931 label_profile: Profil
930 932 label_subtask_plural: Sous-tâches
931 933 label_project_copy_notifications: Envoyer les notifications durant la copie du projet
932 934 label_principal_search: "Rechercher un utilisateur ou un groupe :"
933 935 label_user_search: "Rechercher un utilisateur :"
934 936 label_additional_workflow_transitions_for_author: Autorisations supplémentaires lorsque l'utilisateur a créé la demande
935 937 label_additional_workflow_transitions_for_assignee: Autorisations supplémentaires lorsque la demande est assignée à l'utilisateur
936 938 label_issues_visibility_all: Toutes les demandes
937 939 label_issues_visibility_public: Toutes les demandes non privées
938 940 label_issues_visibility_own: Demandes créées par ou assignées à l'utilisateur
939 941 label_git_report_last_commit: Afficher le dernier commit des fichiers et répertoires
940 942 label_parent_revision: Parent
941 943 label_child_revision: Enfant
942 944 label_export_options: Options d'exportation %{export_format}
943 945 label_copy_attachments: Copier les fichiers
944 946 label_copy_subtasks: Copier les sous-tâches
945 947 label_item_position: "%{position} sur %{count}"
946 948 label_completed_versions: Versions passées
947 949 label_search_for_watchers: Rechercher des observateurs
948 950 label_session_expiration: Expiration des sessions
949 951 label_show_closed_projects: Voir les projets fermés
950 952 label_status_transitions: Changements de statut
951 953 label_fields_permissions: Permissions sur les champs
952 954 label_readonly: Lecture
953 955 label_required: Obligatoire
954 956 label_hidden: Caché
955 957 label_attribute_of_project: "%{name} du projet"
956 958 label_attribute_of_issue: "%{name} de la demande"
957 959 label_attribute_of_author: "%{name} de l'auteur"
958 960 label_attribute_of_assigned_to: "%{name} de l'assigné"
959 961 label_attribute_of_user: "%{name} de l'utilisateur"
960 962 label_attribute_of_fixed_version: "%{name} de la version cible"
961 963 label_attribute_of_object: "%{name} de \"%{object_name}\""
962 964 label_cross_project_descendants: Avec les sous-projets
963 965 label_cross_project_tree: Avec tout l'arbre
964 966 label_cross_project_hierarchy: Avec toute la hiérarchie
965 967 label_cross_project_system: Avec tous les projets
966 968 label_gantt_progress_line: Ligne de progression
967 969 label_visibility_private: par moi uniquement
968 970 label_visibility_roles: par ces rôles uniquement
969 971 label_visibility_public: par tout le monde
970 972 label_link: Lien
971 973 label_only: seulement
972 974 label_drop_down_list: liste déroulante
973 975 label_checkboxes: cases à cocher
974 976 label_radio_buttons: boutons radio
975 977 label_link_values_to: Lier les valeurs vers l'URL
976 978 label_custom_field_select_type: Selectionner le type d'objet auquel attacher le champ personnalisé
977 979 label_check_for_updates: Vérifier les mises à jour
978 980 label_latest_compatible_version: Dernière version compatible
979 981 label_unknown_plugin: Plugin inconnu
980 982 label_add_projects: Ajouter des projets
981 983 label_users_visibility_all: Tous les utilisateurs actifs
982 984 label_users_visibility_members_of_visible_projects: Membres des projets visibles
983 985 label_edit_attachments: Modifier les fichiers attachés
984 986 label_link_copied_issue: Lier la demande copiée
985 987 label_ask: Demander
986 988 label_search_attachments_yes: Rechercher les noms et descriptions de fichiers
987 989 label_search_attachments_no: Ne pas rechercher les fichiers
988 990 label_search_attachments_only: Rechercher les fichiers uniquement
989 991 label_search_open_issues_only: Demandes ouvertes uniquement
990 992 label_email_address_plural: Emails
991 993 label_email_address_add: Ajouter une adresse email
992 994 label_enable_notifications: Activer les notifications
993 995 label_disable_notifications: Désactiver les notifications
994 996 label_blank_value: non renseigné
995 997 label_parent_task_attributes: Attributs des tâches parentes
996 998 label_time_entries_visibility_all: Tous les temps passés
997 999 label_time_entries_visibility_own: Ses propres temps passés
998 1000 label_member_management: Gestion des membres
999 1001 label_member_management_all_roles: Tous les rôles
1000 1002 label_member_management_selected_roles_only: Ces rôles uniquement
1001 1003 label_import_issues: Importer des demandes
1002 1004 label_select_file_to_import: Sélectionner le fichier à importer
1003 1005 label_fields_separator: Séparateur de champs
1004 1006 label_fields_wrapper: Délimiteur de texte
1005 1007 label_encoding: Encodage
1006 1008 label_comma_char: Virgule
1007 1009 label_semi_colon_char: Point virgule
1008 1010 label_quote_char: Apostrophe
1009 1011 label_double_quote_char: Double apostrophe
1010 1012 label_fields_mapping: Correspondance des champs
1011 1013 label_file_content_preview: Aperçu du contenu du fichier
1012 1014 label_create_missing_values: Créer les valeurs manquantes
1013 1015 label_api: API
1014 1016 label_field_format_enumeration: Liste clé/valeur
1015 1017 label_default_values_for_new_users: Valeurs par défaut pour les nouveaux utilisateurs
1016 1018 label_relations: Relations
1017 1019 label_new_project_issue_tab_enabled: Afficher l'onglet "Nouvelle demande"
1018 1020 label_new_object_tab_enabled: Afficher le menu déroulant "+"
1019 1021 label_table_of_contents: Contenu
1020 1022 label_font_default: Police par défaut
1021 1023 label_font_monospace: Police non proportionnelle
1022 1024 label_font_proportional: Police proportionnelle
1023 1025
1024 1026 button_login: Connexion
1025 1027 button_submit: Soumettre
1026 1028 button_save: Sauvegarder
1027 1029 button_check_all: Tout cocher
1028 1030 button_uncheck_all: Tout décocher
1029 1031 button_collapse_all: Plier tout
1030 1032 button_expand_all: Déplier tout
1031 1033 button_delete: Supprimer
1032 1034 button_create: Créer
1033 1035 button_create_and_continue: Créer et continuer
1034 1036 button_test: Tester
1035 1037 button_edit: Modifier
1036 1038 button_edit_associated_wikipage: "Modifier la page wiki associée: %{page_title}"
1037 1039 button_add: Ajouter
1038 1040 button_change: Changer
1039 1041 button_apply: Appliquer
1040 1042 button_clear: Effacer
1041 1043 button_lock: Verrouiller
1042 1044 button_unlock: Déverrouiller
1043 1045 button_download: Télécharger
1044 1046 button_list: Lister
1045 1047 button_view: Voir
1046 1048 button_move: Déplacer
1047 1049 button_move_and_follow: Déplacer et suivre
1048 1050 button_back: Retour
1049 1051 button_cancel: Annuler
1050 1052 button_activate: Activer
1051 1053 button_sort: Trier
1052 1054 button_log_time: Saisir temps
1053 1055 button_rollback: Revenir à cette version
1054 1056 button_watch: Surveiller
1055 1057 button_unwatch: Ne plus surveiller
1056 1058 button_reply: Répondre
1057 1059 button_archive: Archiver
1058 1060 button_unarchive: Désarchiver
1059 1061 button_reset: Réinitialiser
1060 1062 button_rename: Renommer
1061 1063 button_change_password: Changer de mot de passe
1062 1064 button_copy: Copier
1063 1065 button_copy_and_follow: Copier et suivre
1064 1066 button_annotate: Annoter
1065 1067 button_update: Mettre à jour
1066 1068 button_configure: Configurer
1067 1069 button_quote: Citer
1068 1070 button_duplicate: Dupliquer
1069 1071 button_show: Afficher
1070 1072 button_hide: Cacher
1071 1073 button_edit_section: Modifier cette section
1072 1074 button_export: Exporter
1073 1075 button_delete_my_account: Supprimer mon compte
1074 1076 button_close: Fermer
1075 1077 button_reopen: Réouvrir
1076 1078 button_import: Importer
1077 1079 button_filter: Filtrer
1078 1080
1079 1081 status_active: actif
1080 1082 status_registered: enregistré
1081 1083 status_locked: verrouillé
1082 1084
1083 1085 project_status_active: actif
1084 1086 project_status_closed: fermé
1085 1087 project_status_archived: archivé
1086 1088
1087 1089 version_status_open: ouvert
1088 1090 version_status_locked: verrouillé
1089 1091 version_status_closed: fermé
1090 1092
1091 1093 field_active: Actif
1092 1094
1093 1095 text_select_mail_notifications: Actions pour lesquelles une notification par e-mail est envoyée
1094 1096 text_regexp_info: ex. ^[A-Z0-9]+$
1095 1097 text_min_max_length_info: 0 pour aucune restriction
1096 1098 text_project_destroy_confirmation: Êtes-vous sûr de vouloir supprimer ce projet et toutes ses données ?
1097 1099 text_subprojects_destroy_warning: "Ses sous-projets : %{value} seront également supprimés."
1098 1100 text_workflow_edit: Sélectionner un tracker et un rôle pour éditer le workflow
1099 1101 text_are_you_sure: Êtes-vous sûr ?
1100 1102 text_journal_changed: "%{label} changé de %{old} à %{new}"
1101 1103 text_journal_changed_no_detail: "%{label} mis à jour"
1102 1104 text_journal_set_to: "%{label} mis à %{value}"
1103 1105 text_journal_deleted: "%{label} %{old} supprimé"
1104 1106 text_journal_added: "%{label} %{value} ajouté"
1105 1107 text_tip_issue_begin_day: tâche commençant ce jour
1106 1108 text_tip_issue_end_day: tâche finissant ce jour
1107 1109 text_tip_issue_begin_end_day: tâche commençant et finissant ce jour
1108 1110 text_project_identifier_info: 'Seuls les lettres minuscules (a-z), chiffres, tirets et tirets bas sont autorisés, doit commencer par une minuscule.<br />Un fois sauvegardé, l''identifiant ne pourra plus être modifié.'
1109 1111 text_caracters_maximum: "%{count} caractères maximum."
1110 1112 text_caracters_minimum: "%{count} caractères minimum."
1111 1113 text_length_between: "Longueur comprise entre %{min} et %{max} caractères."
1112 1114 text_tracker_no_workflow: Aucun worflow n'est défini pour ce tracker
1113 1115 text_unallowed_characters: Caractères non autorisés
1114 1116 text_comma_separated: Plusieurs valeurs possibles (séparées par des virgules).
1115 1117 text_line_separated: Plusieurs valeurs possibles (une valeur par ligne).
1116 1118 text_issues_ref_in_commit_messages: Référencement et résolution des demandes dans les commentaires de commits
1117 1119 text_issue_added: "La demande %{id} a été soumise par %{author}."
1118 1120 text_issue_updated: "La demande %{id} a été mise à jour par %{author}."
1119 1121 text_wiki_destroy_confirmation: Etes-vous sûr de vouloir supprimer ce wiki et tout son contenu ?
1120 1122 text_issue_category_destroy_question: "%{count} demandes sont affectées à cette catégorie. Que voulez-vous faire ?"
1121 1123 text_issue_category_destroy_assignments: N'affecter les demandes à aucune autre catégorie
1122 1124 text_issue_category_reassign_to: Réaffecter les demandes à cette catégorie
1123 1125 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)."
1124 1126 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é."
1125 1127 text_load_default_configuration: Charger le paramétrage par défaut
1126 1128 text_status_changed_by_changeset: "Appliqué par commit %{value}."
1127 1129 text_time_logged_by_changeset: "Appliqué par commit %{value}"
1128 1130 text_issues_destroy_confirmation: 'Êtes-vous sûr de vouloir supprimer la ou les demandes(s) selectionnée(s) ?'
1129 1131 text_issues_destroy_descendants_confirmation: "Cela entrainera également la suppression de %{count} sous-tâche(s)."
1130 1132 text_time_entries_destroy_confirmation: "Etes-vous sûr de vouloir supprimer les temps passés sélectionnés ?"
1131 1133 text_select_project_modules: 'Sélectionner les modules à activer pour ce projet :'
1132 1134 text_default_administrator_account_changed: Compte administrateur par défaut changé
1133 1135 text_file_repository_writable: Répertoire de stockage des fichiers accessible en écriture
1134 1136 text_plugin_assets_writable: Répertoire public des plugins accessible en écriture
1135 1137 text_rmagick_available: Bibliothèque RMagick présente (optionnelle)
1136 1138 text_convert_available: Binaire convert de ImageMagick présent (optionel)
1137 1139 text_destroy_time_entries_question: "%{hours} heures ont été enregistrées sur les demandes à supprimer. Que voulez-vous faire ?"
1138 1140 text_destroy_time_entries: Supprimer les heures
1139 1141 text_assign_time_entries_to_project: Reporter les heures sur le projet
1140 1142 text_reassign_time_entries: 'Reporter les heures sur cette demande:'
1141 1143 text_user_wrote: "%{value} a écrit :"
1142 1144 text_enumeration_destroy_question: "La valeur « %{name} » est affectée à %{count} objet(s)."
1143 1145 text_enumeration_category_reassign_to: 'Réaffecter les objets à cette valeur:'
1144 1146 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."
1145 1147 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."
1146 1148 text_diff_truncated: '... Ce différentiel a été tronqué car il excède la taille maximale pouvant être affichée.'
1147 1149 text_custom_field_possible_values_info: 'Une ligne par valeur'
1148 1150 text_wiki_page_destroy_question: "Cette page possède %{descendants} sous-page(s) et descendante(s). Que voulez-vous faire ?"
1149 1151 text_wiki_page_nullify_children: "Conserver les sous-pages en tant que pages racines"
1150 1152 text_wiki_page_destroy_children: "Supprimer les sous-pages et toutes leurs descedantes"
1151 1153 text_wiki_page_reassign_children: "Réaffecter les sous-pages à cette page"
1152 1154 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 ?"
1153 1155 text_zoom_in: Zoom avant
1154 1156 text_zoom_out: Zoom arrière
1155 1157 text_warn_on_leaving_unsaved: "Cette page contient du texte non sauvegardé qui sera perdu si vous quittez la page."
1156 1158 text_scm_path_encoding_note: "Défaut : UTF-8"
1157 1159 text_subversion_repository_note: "Exemples (en fonction des protocoles supportés) : file:///, http://, https://, svn://, svn+[tunnelscheme]://"
1158 1160 text_git_repository_note: "Chemin vers un dépôt vide et local (exemples : /gitrepo, c:\\gitrepo)"
1159 1161 text_mercurial_repository_note: "Chemin vers un dépôt local (exemples : /hgrepo, c:\\hgrepo)"
1160 1162 text_scm_command: Commande
1161 1163 text_scm_command_version: Version
1162 1164 text_scm_config: Vous pouvez configurer les commandes des SCM dans config/configuration.yml. Redémarrer l'application après modification.
1163 1165 text_scm_command_not_available: Ce SCM n'est pas disponible. Vérifier les paramètres dans la section administration.
1164 1166 text_issue_conflict_resolution_overwrite: "Appliquer quand même ma mise à jour (les notes précédentes seront conservées mais des changements pourront être écrasés)"
1165 1167 text_issue_conflict_resolution_add_notes: "Ajouter mes notes et ignorer mes autres changements"
1166 1168 text_issue_conflict_resolution_cancel: "Annuler ma mise à jour et réafficher %{link}"
1167 1169 text_account_destroy_confirmation: "Êtes-vous sûr de vouloir continuer ?\nVotre compte sera définitivement supprimé, sans aucune possibilité de le réactiver."
1168 1170 text_session_expiration_settings: "Attention : le changement de ces paramètres peut entrainer l'expiration des sessions utilisateurs en cours, y compris la vôtre."
1169 1171 text_project_closed: Ce projet est fermé et accessible en lecture seule.
1170 1172 text_turning_multiple_off: "Si vous désactivez les valeurs multiples, les valeurs multiples seront supprimées pour n'en conserver qu'une par objet."
1171 1173
1172 1174 default_role_manager: Manager
1173 1175 default_role_developer: Développeur
1174 1176 default_role_reporter: Rapporteur
1175 1177 default_tracker_bug: Anomalie
1176 1178 default_tracker_feature: Evolution
1177 1179 default_tracker_support: Assistance
1178 1180 default_issue_status_new: Nouveau
1179 1181 default_issue_status_in_progress: En cours
1180 1182 default_issue_status_resolved: Résolu
1181 1183 default_issue_status_feedback: Commentaire
1182 1184 default_issue_status_closed: Fermé
1183 1185 default_issue_status_rejected: Rejeté
1184 1186 default_doc_category_user: Documentation utilisateur
1185 1187 default_doc_category_tech: Documentation technique
1186 1188 default_priority_low: Bas
1187 1189 default_priority_normal: Normal
1188 1190 default_priority_high: Haut
1189 1191 default_priority_urgent: Urgent
1190 1192 default_priority_immediate: Immédiat
1191 1193 default_activity_design: Conception
1192 1194 default_activity_development: Développement
1193 1195
1194 1196 enumeration_issue_priorities: Priorités des demandes
1195 1197 enumeration_doc_categories: Catégories des documents
1196 1198 enumeration_activities: Activités (suivi du temps)
1197 1199 enumeration_system_activity: Activité système
1198 1200 description_filter: Filtre
1199 1201 description_search: Champ de recherche
1200 1202 description_choose_project: Projets
1201 1203 description_project_scope: Périmètre de recherche
1202 1204 description_notes: Notes
1203 1205 description_message_content: Contenu du message
1204 1206 description_query_sort_criteria_attribute: Critère de tri
1205 1207 description_query_sort_criteria_direction: Ordre de tri
1206 1208 description_user_mail_notification: Option de notification
1207 1209 description_available_columns: Colonnes disponibles
1208 1210 description_selected_columns: Colonnes sélectionnées
1209 1211 description_all_columns: Toutes les colonnes
1210 1212 description_issue_category_reassign: Choisir une catégorie
1211 1213 description_wiki_subpages_reassign: Choisir une nouvelle page parent
1212 1214 description_date_range_list: Choisir une période prédéfinie
1213 1215 description_date_range_interval: Choisir une période
1214 1216 description_date_from: Date de début
1215 1217 description_date_to: Date de fin
1216 1218 text_repository_identifier_info: 'Seuls les lettres minuscules (a-z), chiffres, tirets et tirets bas sont autorisés.<br />Un fois sauvegardé, l''identifiant ne pourra plus être modifié.'
1217 1219 label_parent_task_attributes_derived: Calculé à partir des sous-tâches
1218 1220 label_parent_task_attributes_independent: Indépendent des sous-tâches
1219 1221 mail_subject_security_notification: Notification de sécurité
1220 1222 mail_body_security_notification_change: ! '%{field} modifié(e).'
1221 1223 mail_body_security_notification_change_to: ! '%{field} changé(e) en %{value}.'
1222 1224 mail_body_security_notification_add: ! '%{field} %{value} ajouté(e).'
1223 1225 mail_body_security_notification_remove: ! '%{field} %{value} supprimé(e).'
1224 1226 mail_body_security_notification_notify_enabled: Les notifications ont été activées pour l'adresse %{value}
1225 1227 mail_body_security_notification_notify_disabled: Les notifications ont été désactivées pour l'adresse %{value}
1226 1228 field_remote_ip: Adresse IP
1227 1229 label_no_preview: No preview available
1228 1230 label_user_mail_option_only_assigned: Only for things I watch or I am assigned to
1229 1231 label_user_mail_option_only_owner: Only for things I watch or I am the owner of
@@ -1,1918 +1,2005
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2016 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require File.expand_path('../../test_helper', __FILE__)
21 21
22 22 class QueryTest < ActiveSupport::TestCase
23 23 include Redmine::I18n
24 24
25 25 fixtures :projects, :enabled_modules, :users, :members,
26 26 :member_roles, :roles, :trackers, :issue_statuses,
27 27 :issue_categories, :enumerations, :issues,
28 28 :watchers, :custom_fields, :custom_values, :versions,
29 29 :queries,
30 30 :projects_trackers,
31 31 :custom_fields_trackers,
32 32 :workflows
33 33
34 34 def setup
35 35 User.current = nil
36 36 end
37 37
38 38 def test_query_with_roles_visibility_should_validate_roles
39 39 set_language_if_valid 'en'
40 40 query = IssueQuery.new(:name => 'Query', :visibility => IssueQuery::VISIBILITY_ROLES)
41 41 assert !query.save
42 42 assert_include "Roles cannot be blank", query.errors.full_messages
43 43 query.role_ids = [1, 2]
44 44 assert query.save
45 45 end
46 46
47 47 def test_changing_roles_visibility_should_clear_roles
48 48 query = IssueQuery.create!(:name => 'Query', :visibility => IssueQuery::VISIBILITY_ROLES, :role_ids => [1, 2])
49 49 assert_equal 2, query.roles.count
50 50
51 51 query.visibility = IssueQuery::VISIBILITY_PUBLIC
52 52 query.save!
53 53 assert_equal 0, query.roles.count
54 54 end
55 55
56 56 def test_available_filters_should_be_ordered
57 57 set_language_if_valid 'en'
58 58 query = IssueQuery.new
59 59 assert_equal 0, query.available_filters.keys.index('status_id')
60 60 expected_order = [
61 61 "Status",
62 62 "Project",
63 63 "Tracker",
64 64 "Priority"
65 65 ]
66 66 assert_equal expected_order,
67 67 (query.available_filters.values.map{|v| v[:name]} & expected_order)
68 68 end
69 69
70 70 def test_available_filters_with_custom_fields_should_be_ordered
71 71 set_language_if_valid 'en'
72 72 UserCustomField.create!(
73 73 :name => 'order test', :field_format => 'string',
74 74 :is_for_all => true, :is_filter => true
75 75 )
76 76 query = IssueQuery.new
77 77 expected_order = [
78 78 "Searchable field",
79 79 "Database",
80 80 "Project's Development status",
81 81 "Author's order test",
82 82 "Assignee's order test"
83 83 ]
84 84 assert_equal expected_order,
85 85 (query.available_filters.values.map{|v| v[:name]} & expected_order)
86 86 end
87 87
88 88 def test_custom_fields_for_all_projects_should_be_available_in_global_queries
89 89 query = IssueQuery.new(:project => nil, :name => '_')
90 90 assert query.available_filters.has_key?('cf_1')
91 91 assert !query.available_filters.has_key?('cf_3')
92 92 end
93 93
94 94 def test_system_shared_versions_should_be_available_in_global_queries
95 95 Version.find(2).update_attribute :sharing, 'system'
96 96 query = IssueQuery.new(:project => nil, :name => '_')
97 97 assert query.available_filters.has_key?('fixed_version_id')
98 98 assert query.available_filters['fixed_version_id'][:values].detect {|v| v[1] == '2'}
99 99 end
100 100
101 101 def test_project_filter_in_global_queries
102 102 query = IssueQuery.new(:project => nil, :name => '_')
103 103 project_filter = query.available_filters["project_id"]
104 104 assert_not_nil project_filter
105 105 project_ids = project_filter[:values].map{|p| p[1]}
106 106 assert project_ids.include?("1") #public project
107 107 assert !project_ids.include?("2") #private project user cannot see
108 108 end
109 109
110 110 def test_available_filters_should_not_include_fields_disabled_on_all_trackers
111 111 Tracker.all.each do |tracker|
112 112 tracker.core_fields = Tracker::CORE_FIELDS - ['start_date']
113 113 tracker.save!
114 114 end
115 115
116 116 query = IssueQuery.new(:name => '_')
117 117 assert_include 'due_date', query.available_filters
118 118 assert_not_include 'start_date', query.available_filters
119 119 end
120 120
121 121 def test_filter_values_without_project_should_be_arrays
122 122 q = IssueQuery.new
123 123 assert_nil q.project
124 124
125 125 q.available_filters.each do |name, filter|
126 126 values = filter.values
127 127 assert (values.nil? || values.is_a?(Array)),
128 128 "#values for #{name} filter returned a #{values.class.name}"
129 129 end
130 130 end
131 131
132 132 def test_filter_values_with_project_should_be_arrays
133 133 q = IssueQuery.new(:project => Project.find(1))
134 134 assert_not_nil q.project
135 135
136 136 q.available_filters.each do |name, filter|
137 137 values = filter.values
138 138 assert (values.nil? || values.is_a?(Array)),
139 139 "#values for #{name} filter returned a #{values.class.name}"
140 140 end
141 141 end
142 142
143 143 def find_issues_with_query(query)
144 144 Issue.joins(:status, :tracker, :project, :priority).where(
145 145 query.statement
146 146 ).to_a
147 147 end
148 148
149 149 def assert_find_issues_with_query_is_successful(query)
150 150 assert_nothing_raised do
151 151 find_issues_with_query(query)
152 152 end
153 153 end
154 154
155 155 def assert_query_statement_includes(query, condition)
156 156 assert_include condition, query.statement
157 157 end
158 158
159 159 def assert_query_result(expected, query)
160 160 assert_nothing_raised do
161 161 assert_equal expected.map(&:id).sort, query.issues.map(&:id).sort
162 162 assert_equal expected.size, query.issue_count
163 163 end
164 164 end
165 165
166 166 def test_query_should_allow_shared_versions_for_a_project_query
167 167 subproject_version = Version.find(4)
168 168 query = IssueQuery.new(:project => Project.find(1), :name => '_')
169 169 filter = query.available_filters["fixed_version_id"]
170 170 assert_not_nil filter
171 171 assert_include subproject_version.id.to_s, filter[:values].map(&:second)
172 172 end
173 173
174 174 def test_query_with_multiple_custom_fields
175 175 query = IssueQuery.find(1)
176 176 assert query.valid?
177 177 issues = find_issues_with_query(query)
178 178 assert_equal 1, issues.length
179 179 assert_equal Issue.find(3), issues.first
180 180 end
181 181
182 182 def test_operator_none
183 183 query = IssueQuery.new(:project => Project.find(1), :name => '_')
184 184 query.add_filter('fixed_version_id', '!*', [''])
185 185 query.add_filter('cf_1', '!*', [''])
186 186 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IS NULL")
187 187 assert query.statement.include?("#{CustomValue.table_name}.value IS NULL OR #{CustomValue.table_name}.value = ''")
188 188 find_issues_with_query(query)
189 189 end
190 190
191 191 def test_operator_none_for_integer
192 192 query = IssueQuery.new(:project => Project.find(1), :name => '_')
193 193 query.add_filter('estimated_hours', '!*', [''])
194 194 issues = find_issues_with_query(query)
195 195 assert !issues.empty?
196 196 assert issues.all? {|i| !i.estimated_hours}
197 197 end
198 198
199 199 def test_operator_none_for_date
200 200 query = IssueQuery.new(:project => Project.find(1), :name => '_')
201 201 query.add_filter('start_date', '!*', [''])
202 202 issues = find_issues_with_query(query)
203 203 assert !issues.empty?
204 204 assert issues.all? {|i| i.start_date.nil?}
205 205 end
206 206
207 207 def test_operator_none_for_string_custom_field
208 208 CustomField.find(2).update_attribute :default_value, ""
209 209 query = IssueQuery.new(:project => Project.find(1), :name => '_')
210 210 query.add_filter('cf_2', '!*', [''])
211 211 assert query.has_filter?('cf_2')
212 212 issues = find_issues_with_query(query)
213 213 assert !issues.empty?
214 214 assert issues.all? {|i| i.custom_field_value(2).blank?}
215 215 end
216 216
217 217 def test_operator_all
218 218 query = IssueQuery.new(:project => Project.find(1), :name => '_')
219 219 query.add_filter('fixed_version_id', '*', [''])
220 220 query.add_filter('cf_1', '*', [''])
221 221 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IS NOT NULL")
222 222 assert query.statement.include?("#{CustomValue.table_name}.value IS NOT NULL AND #{CustomValue.table_name}.value <> ''")
223 223 find_issues_with_query(query)
224 224 end
225 225
226 226 def test_operator_all_for_date
227 227 query = IssueQuery.new(:project => Project.find(1), :name => '_')
228 228 query.add_filter('start_date', '*', [''])
229 229 issues = find_issues_with_query(query)
230 230 assert !issues.empty?
231 231 assert issues.all? {|i| i.start_date.present?}
232 232 end
233 233
234 234 def test_operator_all_for_string_custom_field
235 235 query = IssueQuery.new(:project => Project.find(1), :name => '_')
236 236 query.add_filter('cf_2', '*', [''])
237 237 assert query.has_filter?('cf_2')
238 238 issues = find_issues_with_query(query)
239 239 assert !issues.empty?
240 240 assert issues.all? {|i| i.custom_field_value(2).present?}
241 241 end
242 242
243 243 def test_numeric_filter_should_not_accept_non_numeric_values
244 244 query = IssueQuery.new(:name => '_')
245 245 query.add_filter('estimated_hours', '=', ['a'])
246 246
247 247 assert query.has_filter?('estimated_hours')
248 248 assert !query.valid?
249 249 end
250 250
251 251 def test_operator_is_on_float
252 252 Issue.where(:id => 2).update_all("estimated_hours = 171.2")
253 253 query = IssueQuery.new(:name => '_')
254 254 query.add_filter('estimated_hours', '=', ['171.20'])
255 255 issues = find_issues_with_query(query)
256 256 assert_equal 1, issues.size
257 257 assert_equal 2, issues.first.id
258 258 end
259 259
260 260 def test_operator_is_on_issue_id_should_accept_comma_separated_values
261 261 query = IssueQuery.new(:name => '_')
262 262 query.add_filter("issue_id", '=', ['1,3'])
263 263 issues = find_issues_with_query(query)
264 264 assert_equal 2, issues.size
265 265 assert_equal [1,3], issues.map(&:id).sort
266 266 end
267 267
268 268 def test_operator_between_on_issue_id_should_return_range
269 269 query = IssueQuery.new(:name => '_')
270 270 query.add_filter("issue_id", '><', ['2','3'])
271 271 issues = find_issues_with_query(query)
272 272 assert_equal 2, issues.size
273 273 assert_equal [2,3], issues.map(&:id).sort
274 274 end
275 275
276 276 def test_operator_is_on_integer_custom_field
277 277 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_for_all => true, :is_filter => true, :trackers => Tracker.all)
278 278 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7')
279 279 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '12')
280 280 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
281 281
282 282 query = IssueQuery.new(:name => '_')
283 283 query.add_filter("cf_#{f.id}", '=', ['12'])
284 284 issues = find_issues_with_query(query)
285 285 assert_equal 1, issues.size
286 286 assert_equal 2, issues.first.id
287 287 end
288 288
289 289 def test_operator_is_on_integer_custom_field_should_accept_negative_value
290 290 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_for_all => true, :is_filter => true, :trackers => Tracker.all)
291 291 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7')
292 292 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '-12')
293 293 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
294 294
295 295 query = IssueQuery.new(:name => '_')
296 296 query.add_filter("cf_#{f.id}", '=', ['-12'])
297 297 assert query.valid?
298 298 issues = find_issues_with_query(query)
299 299 assert_equal 1, issues.size
300 300 assert_equal 2, issues.first.id
301 301 end
302 302
303 303 def test_operator_is_on_float_custom_field
304 304 f = IssueCustomField.create!(:name => 'filter', :field_format => 'float', :is_filter => true, :is_for_all => true, :trackers => Tracker.all)
305 305 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7.3')
306 306 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '12.7')
307 307 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
308 308
309 309 query = IssueQuery.new(:name => '_')
310 310 query.add_filter("cf_#{f.id}", '=', ['12.7'])
311 311 issues = find_issues_with_query(query)
312 312 assert_equal 1, issues.size
313 313 assert_equal 2, issues.first.id
314 314 end
315 315
316 316 def test_operator_is_on_float_custom_field_should_accept_negative_value
317 317 f = IssueCustomField.create!(:name => 'filter', :field_format => 'float', :is_filter => true, :is_for_all => true, :trackers => Tracker.all)
318 318 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7.3')
319 319 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '-12.7')
320 320 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
321 321
322 322 query = IssueQuery.new(:name => '_')
323 323 query.add_filter("cf_#{f.id}", '=', ['-12.7'])
324 324 assert query.valid?
325 325 issues = find_issues_with_query(query)
326 326 assert_equal 1, issues.size
327 327 assert_equal 2, issues.first.id
328 328 end
329 329
330 330 def test_operator_is_on_multi_list_custom_field
331 331 f = IssueCustomField.create!(:name => 'filter', :field_format => 'list', :is_filter => true, :is_for_all => true,
332 332 :possible_values => ['value1', 'value2', 'value3'], :multiple => true, :trackers => Tracker.all)
333 333 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'value1')
334 334 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'value2')
335 335 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => 'value1')
336 336
337 337 query = IssueQuery.new(:name => '_')
338 338 query.add_filter("cf_#{f.id}", '=', ['value1'])
339 339 issues = find_issues_with_query(query)
340 340 assert_equal [1, 3], issues.map(&:id).sort
341 341
342 342 query = IssueQuery.new(:name => '_')
343 343 query.add_filter("cf_#{f.id}", '=', ['value2'])
344 344 issues = find_issues_with_query(query)
345 345 assert_equal [1], issues.map(&:id).sort
346 346 end
347 347
348 348 def test_operator_is_not_on_multi_list_custom_field
349 349 f = IssueCustomField.create!(:name => 'filter', :field_format => 'list', :is_filter => true, :is_for_all => true,
350 350 :possible_values => ['value1', 'value2', 'value3'], :multiple => true, :trackers => Tracker.all)
351 351 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'value1')
352 352 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'value2')
353 353 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => 'value1')
354 354
355 355 query = IssueQuery.new(:name => '_')
356 356 query.add_filter("cf_#{f.id}", '!', ['value1'])
357 357 issues = find_issues_with_query(query)
358 358 assert !issues.map(&:id).include?(1)
359 359 assert !issues.map(&:id).include?(3)
360 360
361 361 query = IssueQuery.new(:name => '_')
362 362 query.add_filter("cf_#{f.id}", '!', ['value2'])
363 363 issues = find_issues_with_query(query)
364 364 assert !issues.map(&:id).include?(1)
365 365 assert issues.map(&:id).include?(3)
366 366 end
367 367
368 368 def test_operator_is_on_string_custom_field_with_utf8_value
369 369 f = IssueCustomField.create!(:name => 'filter', :field_format => 'string', :is_filter => true, :is_for_all => true, :trackers => Tracker.all)
370 370 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'Kiểm')
371 371
372 372 query = IssueQuery.new(:name => '_')
373 373 query.add_filter("cf_#{f.id}", '=', ['Kiểm'])
374 374 issues = find_issues_with_query(query)
375 375 assert_equal [1], issues.map(&:id).sort
376 376 end
377 377
378 378 def test_operator_is_on_is_private_field
379 379 # is_private filter only available for those who can set issues private
380 380 User.current = User.find(2)
381 381
382 382 query = IssueQuery.new(:name => '_')
383 383 assert query.available_filters.key?('is_private')
384 384
385 385 query.add_filter("is_private", '=', ['1'])
386 386 issues = find_issues_with_query(query)
387 387 assert issues.any?
388 388 assert_nil issues.detect {|issue| !issue.is_private?}
389 389 ensure
390 390 User.current = nil
391 391 end
392 392
393 393 def test_operator_is_not_on_is_private_field
394 394 # is_private filter only available for those who can set issues private
395 395 User.current = User.find(2)
396 396
397 397 query = IssueQuery.new(:name => '_')
398 398 assert query.available_filters.key?('is_private')
399 399
400 400 query.add_filter("is_private", '!', ['1'])
401 401 issues = find_issues_with_query(query)
402 402 assert issues.any?
403 403 assert_nil issues.detect {|issue| issue.is_private?}
404 404 ensure
405 405 User.current = nil
406 406 end
407 407
408 408 def test_operator_greater_than
409 409 query = IssueQuery.new(:project => Project.find(1), :name => '_')
410 410 query.add_filter('done_ratio', '>=', ['40'])
411 411 assert query.statement.include?("#{Issue.table_name}.done_ratio >= 40.0")
412 412 find_issues_with_query(query)
413 413 end
414 414
415 415 def test_operator_greater_than_a_float
416 416 query = IssueQuery.new(:project => Project.find(1), :name => '_')
417 417 query.add_filter('estimated_hours', '>=', ['40.5'])
418 418 assert query.statement.include?("#{Issue.table_name}.estimated_hours >= 40.5")
419 419 find_issues_with_query(query)
420 420 end
421 421
422 422 def test_operator_greater_than_on_int_custom_field
423 423 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_filter => true, :is_for_all => true, :trackers => Tracker.all)
424 424 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7')
425 425 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '12')
426 426 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
427 427
428 428 query = IssueQuery.new(:project => Project.find(1), :name => '_')
429 429 query.add_filter("cf_#{f.id}", '>=', ['8'])
430 430 issues = find_issues_with_query(query)
431 431 assert_equal 1, issues.size
432 432 assert_equal 2, issues.first.id
433 433 end
434 434
435 435 def test_operator_lesser_than
436 436 query = IssueQuery.new(:project => Project.find(1), :name => '_')
437 437 query.add_filter('done_ratio', '<=', ['30'])
438 438 assert query.statement.include?("#{Issue.table_name}.done_ratio <= 30.0")
439 439 find_issues_with_query(query)
440 440 end
441 441
442 442 def test_operator_lesser_than_on_custom_field
443 443 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_filter => true, :is_for_all => true)
444 444 query = IssueQuery.new(:project => Project.find(1), :name => '_')
445 445 query.add_filter("cf_#{f.id}", '<=', ['30'])
446 446 assert_match /CAST.+ <= 30\.0/, query.statement
447 447 find_issues_with_query(query)
448 448 end
449 449
450 450 def test_operator_lesser_than_on_date_custom_field
451 451 f = IssueCustomField.create!(:name => 'filter', :field_format => 'date', :is_filter => true, :is_for_all => true, :trackers => Tracker.all)
452 452 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '2013-04-11')
453 453 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '2013-05-14')
454 454 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
455 455
456 456 query = IssueQuery.new(:project => Project.find(1), :name => '_')
457 457 query.add_filter("cf_#{f.id}", '<=', ['2013-05-01'])
458 458 issue_ids = find_issues_with_query(query).map(&:id)
459 459 assert_include 1, issue_ids
460 460 assert_not_include 2, issue_ids
461 461 assert_not_include 3, issue_ids
462 462 end
463 463
464 464 def test_operator_between
465 465 query = IssueQuery.new(:project => Project.find(1), :name => '_')
466 466 query.add_filter('done_ratio', '><', ['30', '40'])
467 467 assert_include "#{Issue.table_name}.done_ratio BETWEEN 30.0 AND 40.0", query.statement
468 468 find_issues_with_query(query)
469 469 end
470 470
471 471 def test_operator_between_on_custom_field
472 472 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_filter => true, :is_for_all => true)
473 473 query = IssueQuery.new(:project => Project.find(1), :name => '_')
474 474 query.add_filter("cf_#{f.id}", '><', ['30', '40'])
475 475 assert_match /CAST.+ BETWEEN 30.0 AND 40.0/, query.statement
476 476 find_issues_with_query(query)
477 477 end
478 478
479 479 def test_date_filter_should_not_accept_non_date_values
480 480 query = IssueQuery.new(:name => '_')
481 481 query.add_filter('created_on', '=', ['a'])
482 482
483 483 assert query.has_filter?('created_on')
484 484 assert !query.valid?
485 485 end
486 486
487 487 def test_date_filter_should_not_accept_invalid_date_values
488 488 query = IssueQuery.new(:name => '_')
489 489 query.add_filter('created_on', '=', ['2011-01-34'])
490 490
491 491 assert query.has_filter?('created_on')
492 492 assert !query.valid?
493 493 end
494 494
495 495 def test_relative_date_filter_should_not_accept_non_integer_values
496 496 query = IssueQuery.new(:name => '_')
497 497 query.add_filter('created_on', '>t-', ['a'])
498 498
499 499 assert query.has_filter?('created_on')
500 500 assert !query.valid?
501 501 end
502 502
503 503 def test_operator_date_equals
504 504 query = IssueQuery.new(:name => '_')
505 505 query.add_filter('due_date', '=', ['2011-07-10'])
506 506 assert_match /issues\.due_date > '#{quoted_date "2011-07-09"} 23:59:59(\.\d+)?' AND issues\.due_date <= '#{quoted_date "2011-07-10"} 23:59:59(\.\d+)?/,
507 507 query.statement
508 508 find_issues_with_query(query)
509 509 end
510 510
511 511 def test_operator_date_lesser_than
512 512 query = IssueQuery.new(:name => '_')
513 513 query.add_filter('due_date', '<=', ['2011-07-10'])
514 514 assert_match /issues\.due_date <= '#{quoted_date "2011-07-10"} 23:59:59(\.\d+)?/, query.statement
515 515 find_issues_with_query(query)
516 516 end
517 517
518 518 def test_operator_date_lesser_than_with_timestamp
519 519 query = IssueQuery.new(:name => '_')
520 520 query.add_filter('updated_on', '<=', ['2011-07-10T19:13:52'])
521 521 assert_match /issues\.updated_on <= '#{quoted_date "2011-07-10"} 19:13:52/, query.statement
522 522 find_issues_with_query(query)
523 523 end
524 524
525 525 def test_operator_date_greater_than
526 526 query = IssueQuery.new(:name => '_')
527 527 query.add_filter('due_date', '>=', ['2011-07-10'])
528 528 assert_match /issues\.due_date > '#{quoted_date "2011-07-09"} 23:59:59(\.\d+)?'/, query.statement
529 529 find_issues_with_query(query)
530 530 end
531 531
532 532 def test_operator_date_greater_than_with_timestamp
533 533 query = IssueQuery.new(:name => '_')
534 534 query.add_filter('updated_on', '>=', ['2011-07-10T19:13:52'])
535 535 assert_match /issues\.updated_on > '#{quoted_date "2011-07-10"} 19:13:51(\.0+)?'/, query.statement
536 536 find_issues_with_query(query)
537 537 end
538 538
539 539 def test_operator_date_between
540 540 query = IssueQuery.new(:name => '_')
541 541 query.add_filter('due_date', '><', ['2011-06-23', '2011-07-10'])
542 542 assert_match /issues\.due_date > '#{quoted_date "2011-06-22"} 23:59:59(\.\d+)?' AND issues\.due_date <= '#{quoted_date "2011-07-10"} 23:59:59(\.\d+)?'/,
543 543 query.statement
544 544 find_issues_with_query(query)
545 545 end
546 546
547 547 def test_operator_in_more_than
548 548 Issue.find(7).update_attribute(:due_date, (Date.today + 15))
549 549 query = IssueQuery.new(:project => Project.find(1), :name => '_')
550 550 query.add_filter('due_date', '>t+', ['15'])
551 551 issues = find_issues_with_query(query)
552 552 assert !issues.empty?
553 553 issues.each {|issue| assert(issue.due_date >= (Date.today + 15))}
554 554 end
555 555
556 556 def test_operator_in_less_than
557 557 query = IssueQuery.new(:project => Project.find(1), :name => '_')
558 558 query.add_filter('due_date', '<t+', ['15'])
559 559 issues = find_issues_with_query(query)
560 560 assert !issues.empty?
561 561 issues.each {|issue| assert(issue.due_date <= (Date.today + 15))}
562 562 end
563 563
564 564 def test_operator_in_the_next_days
565 565 query = IssueQuery.new(:project => Project.find(1), :name => '_')
566 566 query.add_filter('due_date', '><t+', ['15'])
567 567 issues = find_issues_with_query(query)
568 568 assert !issues.empty?
569 569 issues.each {|issue| assert(issue.due_date >= Date.today && issue.due_date <= (Date.today + 15))}
570 570 end
571 571
572 572 def test_operator_less_than_ago
573 573 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
574 574 query = IssueQuery.new(:project => Project.find(1), :name => '_')
575 575 query.add_filter('due_date', '>t-', ['3'])
576 576 issues = find_issues_with_query(query)
577 577 assert !issues.empty?
578 578 issues.each {|issue| assert(issue.due_date >= (Date.today - 3))}
579 579 end
580 580
581 581 def test_operator_in_the_past_days
582 582 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
583 583 query = IssueQuery.new(:project => Project.find(1), :name => '_')
584 584 query.add_filter('due_date', '><t-', ['3'])
585 585 issues = find_issues_with_query(query)
586 586 assert !issues.empty?
587 587 issues.each {|issue| assert(issue.due_date >= (Date.today - 3) && issue.due_date <= Date.today)}
588 588 end
589 589
590 590 def test_operator_more_than_ago
591 591 Issue.find(7).update_attribute(:due_date, (Date.today - 10))
592 592 query = IssueQuery.new(:project => Project.find(1), :name => '_')
593 593 query.add_filter('due_date', '<t-', ['10'])
594 594 assert query.statement.include?("#{Issue.table_name}.due_date <=")
595 595 issues = find_issues_with_query(query)
596 596 assert !issues.empty?
597 597 issues.each {|issue| assert(issue.due_date <= (Date.today - 10))}
598 598 end
599 599
600 600 def test_operator_in
601 601 Issue.find(7).update_attribute(:due_date, (Date.today + 2))
602 602 query = IssueQuery.new(:project => Project.find(1), :name => '_')
603 603 query.add_filter('due_date', 't+', ['2'])
604 604 issues = find_issues_with_query(query)
605 605 assert !issues.empty?
606 606 issues.each {|issue| assert_equal((Date.today + 2), issue.due_date)}
607 607 end
608 608
609 609 def test_operator_ago
610 610 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
611 611 query = IssueQuery.new(:project => Project.find(1), :name => '_')
612 612 query.add_filter('due_date', 't-', ['3'])
613 613 issues = find_issues_with_query(query)
614 614 assert !issues.empty?
615 615 issues.each {|issue| assert_equal((Date.today - 3), issue.due_date)}
616 616 end
617 617
618 618 def test_operator_today
619 619 query = IssueQuery.new(:project => Project.find(1), :name => '_')
620 620 query.add_filter('due_date', 't', [''])
621 621 issues = find_issues_with_query(query)
622 622 assert !issues.empty?
623 623 issues.each {|issue| assert_equal Date.today, issue.due_date}
624 624 end
625 625
626 626 def test_operator_date_periods
627 627 %w(t ld w lw l2w m lm y).each do |operator|
628 628 query = IssueQuery.new(:name => '_')
629 629 query.add_filter('due_date', operator, [''])
630 630 assert query.valid?
631 631 assert query.issues
632 632 end
633 633 end
634 634
635 635 def test_operator_datetime_periods
636 636 %w(t ld w lw l2w m lm y).each do |operator|
637 637 query = IssueQuery.new(:name => '_')
638 638 query.add_filter('created_on', operator, [''])
639 639 assert query.valid?
640 640 assert query.issues
641 641 end
642 642 end
643 643
644 644 def test_operator_contains
645 645 issue = Issue.generate!(:subject => 'AbCdEfG')
646 646
647 647 query = IssueQuery.new(:name => '_')
648 648 query.add_filter('subject', '~', ['cdeF'])
649 649 result = find_issues_with_query(query)
650 650 assert_include issue, result
651 651 result.each {|issue| assert issue.subject.downcase.include?('cdef') }
652 652 end
653 653
654 654 def test_operator_contains_with_utf8_string
655 655 issue = Issue.generate!(:subject => 'Subject contains Kiểm')
656 656
657 657 query = IssueQuery.new(:name => '_')
658 658 query.add_filter('subject', '~', ['Kiểm'])
659 659 result = find_issues_with_query(query)
660 660 assert_include issue, result
661 661 assert_equal 1, result.size
662 662 end
663 663
664 664 def test_operator_does_not_contain
665 665 issue = Issue.generate!(:subject => 'AbCdEfG')
666 666
667 667 query = IssueQuery.new(:name => '_')
668 668 query.add_filter('subject', '!~', ['cdeF'])
669 669 result = find_issues_with_query(query)
670 670 assert_not_include issue, result
671 671 end
672 672
673 673 def test_range_for_this_week_with_week_starting_on_monday
674 674 I18n.locale = :fr
675 675 assert_equal '1', I18n.t(:general_first_day_of_week)
676 676
677 677 Date.stubs(:today).returns(Date.parse('2011-04-29'))
678 678
679 679 query = IssueQuery.new(:project => Project.find(1), :name => '_')
680 680 query.add_filter('due_date', 'w', [''])
681 681 assert_match /issues\.due_date > '#{quoted_date "2011-04-24"} 23:59:59(\.\d+)?' AND issues\.due_date <= '#{quoted_date "2011-05-01"} 23:59:59(\.\d+)?/,
682 682 query.statement
683 683 I18n.locale = :en
684 684 end
685 685
686 686 def test_range_for_this_week_with_week_starting_on_sunday
687 687 I18n.locale = :en
688 688 assert_equal '7', I18n.t(:general_first_day_of_week)
689 689
690 690 Date.stubs(:today).returns(Date.parse('2011-04-29'))
691 691
692 692 query = IssueQuery.new(:project => Project.find(1), :name => '_')
693 693 query.add_filter('due_date', 'w', [''])
694 694 assert_match /issues\.due_date > '#{quoted_date "2011-04-23"} 23:59:59(\.\d+)?' AND issues\.due_date <= '#{quoted_date "2011-04-30"} 23:59:59(\.\d+)?/,
695 695 query.statement
696 696 end
697 697
698 698 def test_filter_assigned_to_me
699 699 user = User.find(2)
700 700 group = Group.find(10)
701 701 group.users << user
702 702 other_group = Group.find(11)
703 703 Member.create!(:project_id => 1, :principal => group, :role_ids => [1])
704 704 Member.create!(:project_id => 1, :principal => other_group, :role_ids => [1])
705 705 User.current = user
706 706
707 707 with_settings :issue_group_assignment => '1' do
708 708 i1 = Issue.generate!(:project_id => 1, :tracker_id => 1, :assigned_to => user)
709 709 i2 = Issue.generate!(:project_id => 1, :tracker_id => 1, :assigned_to => group)
710 710 i3 = Issue.generate!(:project_id => 1, :tracker_id => 1, :assigned_to => other_group)
711 711
712 712 query = IssueQuery.new(:name => '_', :filters => { 'assigned_to_id' => {:operator => '=', :values => ['me']}})
713 713 result = query.issues
714 714 assert_equal Issue.visible.where(:assigned_to_id => ([2] + user.reload.group_ids)).sort_by(&:id), result.sort_by(&:id)
715 715
716 716 assert result.include?(i1)
717 717 assert result.include?(i2)
718 718 assert !result.include?(i3)
719 719 end
720 720 end
721 721
722 def test_filter_updated_by
723 user = User.generate!
724 Journal.create!(:user_id => user.id, :journalized => Issue.find(2), :notes => 'Notes')
725 Journal.create!(:user_id => user.id, :journalized => Issue.find(3), :notes => 'Notes')
726 Journal.create!(:user_id => 2, :journalized => Issue.find(3), :notes => 'Notes')
727
728 query = IssueQuery.new(:name => '_')
729 filter_name = "updated_by"
730 assert_include filter_name, query.available_filters.keys
731
732 query.filters = {filter_name => {:operator => '=', :values => [user.id]}}
733 assert_equal [2, 3], find_issues_with_query(query).map(&:id).sort
734
735 query.filters = {filter_name => {:operator => '!', :values => [user.id]}}
736 assert_equal (Issue.ids.sort - [2, 3]), find_issues_with_query(query).map(&:id).sort
737 end
738
739 def test_filter_updated_by_should_ignore_private_notes_that_are_not_visible
740 user = User.generate!
741 Journal.create!(:user_id => user.id, :journalized => Issue.find(2), :notes => 'Notes', :private_notes => true)
742 Journal.create!(:user_id => user.id, :journalized => Issue.find(3), :notes => 'Notes')
743
744 query = IssueQuery.new(:name => '_')
745 filter_name = "updated_by"
746 assert_include filter_name, query.available_filters.keys
747
748 with_current_user User.anonymous do
749 query.filters = {filter_name => {:operator => '=', :values => [user.id]}}
750 assert_equal [3], find_issues_with_query(query).map(&:id).sort
751 end
752 end
753
754 def test_filter_updated_by_me
755 user = User.generate!
756 Journal.create!(:user_id => user.id, :journalized => Issue.find(2), :notes => 'Notes')
757
758 with_current_user user do
759 query = IssueQuery.new(:name => '_')
760 filter_name = "updated_by"
761 assert_include filter_name, query.available_filters.keys
762
763 query.filters = {filter_name => {:operator => '=', :values => ['me']}}
764 assert_equal [2], find_issues_with_query(query).map(&:id).sort
765 end
766 end
767
768 def test_filter_last_updated_by
769 user = User.generate!
770 Journal.create!(:user_id => user.id, :journalized => Issue.find(2), :notes => 'Notes')
771 Journal.create!(:user_id => user.id, :journalized => Issue.find(3), :notes => 'Notes')
772 Journal.create!(:user_id => 2, :journalized => Issue.find(3), :notes => 'Notes')
773
774 query = IssueQuery.new(:name => '_')
775 filter_name = "last_updated_by"
776 assert_include filter_name, query.available_filters.keys
777
778 query.filters = {filter_name => {:operator => '=', :values => [user.id]}}
779 assert_equal [2], find_issues_with_query(query).map(&:id).sort
780 end
781
782 def test_filter_last_updated_by_should_ignore_private_notes_that_are_not_visible
783 user1 = User.generate!
784 user2 = User.generate!
785 Journal.create!(:user_id => user1.id, :journalized => Issue.find(2), :notes => 'Notes')
786 Journal.create!(:user_id => user2.id, :journalized => Issue.find(2), :notes => 'Notes', :private_notes => true)
787
788 query = IssueQuery.new(:name => '_')
789 filter_name = "last_updated_by"
790 assert_include filter_name, query.available_filters.keys
791
792 with_current_user User.anonymous do
793 query.filters = {filter_name => {:operator => '=', :values => [user1.id]}}
794 assert_equal [2], find_issues_with_query(query).map(&:id).sort
795
796 query.filters = {filter_name => {:operator => '=', :values => [user2.id]}}
797 assert_equal [], find_issues_with_query(query).map(&:id).sort
798 end
799
800 with_current_user User.find(2) do
801 query.filters = {filter_name => {:operator => '=', :values => [user1.id]}}
802 assert_equal [], find_issues_with_query(query).map(&:id).sort
803
804 query.filters = {filter_name => {:operator => '=', :values => [user2.id]}}
805 assert_equal [2], find_issues_with_query(query).map(&:id).sort
806 end
807 end
808
722 809 def test_user_custom_field_filtered_on_me
723 810 User.current = User.find(2)
724 811 cf = IssueCustomField.create!(:field_format => 'user', :is_for_all => true, :is_filter => true, :name => 'User custom field', :tracker_ids => [1])
725 812 issue1 = Issue.create!(:project_id => 1, :tracker_id => 1, :custom_field_values => {cf.id.to_s => '2'}, :subject => 'Test', :author_id => 1)
726 813 issue2 = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {cf.id.to_s => '3'})
727 814
728 815 query = IssueQuery.new(:name => '_', :project => Project.find(1))
729 816 filter = query.available_filters["cf_#{cf.id}"]
730 817 assert_not_nil filter
731 818 assert_include 'me', filter[:values].map{|v| v[1]}
732 819
733 820 query.filters = { "cf_#{cf.id}" => {:operator => '=', :values => ['me']}}
734 821 result = query.issues
735 822 assert_equal 1, result.size
736 823 assert_equal issue1, result.first
737 824 end
738 825
739 826 def test_filter_on_me_by_anonymous_user
740 827 User.current = nil
741 828 query = IssueQuery.new(:name => '_', :filters => { 'assigned_to_id' => {:operator => '=', :values => ['me']}})
742 829 assert_equal [], query.issues
743 830 end
744 831
745 832 def test_filter_my_projects
746 833 User.current = User.find(2)
747 834 query = IssueQuery.new(:name => '_')
748 835 filter = query.available_filters['project_id']
749 836 assert_not_nil filter
750 837 assert_include 'mine', filter[:values].map{|v| v[1]}
751 838
752 839 query.filters = { 'project_id' => {:operator => '=', :values => ['mine']}}
753 840 result = query.issues
754 841 assert_nil result.detect {|issue| !User.current.member_of?(issue.project)}
755 842 end
756 843
757 844 def test_filter_watched_issues
758 845 User.current = User.find(1)
759 846 query = IssueQuery.new(:name => '_', :filters => { 'watcher_id' => {:operator => '=', :values => ['me']}})
760 847 result = find_issues_with_query(query)
761 848 assert_not_nil result
762 849 assert !result.empty?
763 850 assert_equal Issue.visible.watched_by(User.current).sort_by(&:id), result.sort_by(&:id)
764 851 User.current = nil
765 852 end
766 853
767 854 def test_filter_unwatched_issues
768 855 User.current = User.find(1)
769 856 query = IssueQuery.new(:name => '_', :filters => { 'watcher_id' => {:operator => '!', :values => ['me']}})
770 857 result = find_issues_with_query(query)
771 858 assert_not_nil result
772 859 assert !result.empty?
773 860 assert_equal((Issue.visible - Issue.watched_by(User.current)).sort_by(&:id).size, result.sort_by(&:id).size)
774 861 User.current = nil
775 862 end
776 863
777 864 def test_filter_on_custom_field_should_ignore_projects_with_field_disabled
778 865 field = IssueCustomField.generate!(:trackers => Tracker.all, :project_ids => [1, 3, 4], :is_for_all => false, :is_filter => true)
779 866 Issue.generate!(:project_id => 3, :tracker_id => 2, :custom_field_values => {field.id.to_s => 'Foo'})
780 867 Issue.generate!(:project_id => 4, :tracker_id => 2, :custom_field_values => {field.id.to_s => 'Foo'})
781 868
782 869 query = IssueQuery.new(:name => '_', :project => Project.find(1))
783 870 query.filters = {"cf_#{field.id}" => {:operator => '=', :values => ['Foo']}}
784 871 assert_equal 2, find_issues_with_query(query).size
785 872
786 873 field.project_ids = [1, 3] # Disable the field for project 4
787 874 field.save!
788 875 assert_equal 1, find_issues_with_query(query).size
789 876 end
790 877
791 878 def test_filter_on_custom_field_should_ignore_trackers_with_field_disabled
792 879 field = IssueCustomField.generate!(:tracker_ids => [1, 2], :is_for_all => true, :is_filter => true)
793 880 Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {field.id.to_s => 'Foo'})
794 881 Issue.generate!(:project_id => 1, :tracker_id => 2, :custom_field_values => {field.id.to_s => 'Foo'})
795 882
796 883 query = IssueQuery.new(:name => '_', :project => Project.find(1))
797 884 query.filters = {"cf_#{field.id}" => {:operator => '=', :values => ['Foo']}}
798 885 assert_equal 2, find_issues_with_query(query).size
799 886
800 887 field.tracker_ids = [1] # Disable the field for tracker 2
801 888 field.save!
802 889 assert_equal 1, find_issues_with_query(query).size
803 890 end
804 891
805 892 def test_filter_on_project_custom_field
806 893 field = ProjectCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
807 894 CustomValue.create!(:custom_field => field, :customized => Project.find(3), :value => 'Foo')
808 895 CustomValue.create!(:custom_field => field, :customized => Project.find(5), :value => 'Foo')
809 896
810 897 query = IssueQuery.new(:name => '_')
811 898 filter_name = "project.cf_#{field.id}"
812 899 assert_include filter_name, query.available_filters.keys
813 900 query.filters = {filter_name => {:operator => '=', :values => ['Foo']}}
814 901 assert_equal [3, 5], find_issues_with_query(query).map(&:project_id).uniq.sort
815 902 end
816 903
817 904 def test_filter_on_author_custom_field
818 905 field = UserCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
819 906 CustomValue.create!(:custom_field => field, :customized => User.find(3), :value => 'Foo')
820 907
821 908 query = IssueQuery.new(:name => '_')
822 909 filter_name = "author.cf_#{field.id}"
823 910 assert_include filter_name, query.available_filters.keys
824 911 query.filters = {filter_name => {:operator => '=', :values => ['Foo']}}
825 912 assert_equal [3], find_issues_with_query(query).map(&:author_id).uniq.sort
826 913 end
827 914
828 915 def test_filter_on_assigned_to_custom_field
829 916 field = UserCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
830 917 CustomValue.create!(:custom_field => field, :customized => User.find(3), :value => 'Foo')
831 918
832 919 query = IssueQuery.new(:name => '_')
833 920 filter_name = "assigned_to.cf_#{field.id}"
834 921 assert_include filter_name, query.available_filters.keys
835 922 query.filters = {filter_name => {:operator => '=', :values => ['Foo']}}
836 923 assert_equal [3], find_issues_with_query(query).map(&:assigned_to_id).uniq.sort
837 924 end
838 925
839 926 def test_filter_on_fixed_version_custom_field
840 927 field = VersionCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
841 928 CustomValue.create!(:custom_field => field, :customized => Version.find(2), :value => 'Foo')
842 929
843 930 query = IssueQuery.new(:name => '_')
844 931 filter_name = "fixed_version.cf_#{field.id}"
845 932 assert_include filter_name, query.available_filters.keys
846 933 query.filters = {filter_name => {:operator => '=', :values => ['Foo']}}
847 934 assert_equal [2], find_issues_with_query(query).map(&:fixed_version_id).uniq.sort
848 935 end
849 936
850 937 def test_filter_on_fixed_version_due_date
851 938 query = IssueQuery.new(:name => '_')
852 939 filter_name = "fixed_version.due_date"
853 940 assert_include filter_name, query.available_filters.keys
854 941 query.filters = {filter_name => {:operator => '=', :values => [20.day.from_now.to_date.to_s(:db)]}}
855 942 issues = find_issues_with_query(query)
856 943 assert_equal [2], issues.map(&:fixed_version_id).uniq.sort
857 944 assert_equal [2, 12], issues.map(&:id).sort
858 945
859 946 query = IssueQuery.new(:name => '_')
860 947 query.filters = {filter_name => {:operator => '>=', :values => [21.day.from_now.to_date.to_s(:db)]}}
861 948 assert_equal 0, find_issues_with_query(query).size
862 949 end
863 950
864 951 def test_filter_on_fixed_version_status
865 952 query = IssueQuery.new(:name => '_')
866 953 filter_name = "fixed_version.status"
867 954 assert_include filter_name, query.available_filters.keys
868 955 query.filters = {filter_name => {:operator => '=', :values => ['closed']}}
869 956 issues = find_issues_with_query(query)
870 957
871 958 assert_equal [1], issues.map(&:fixed_version_id).sort
872 959 assert_equal [11], issues.map(&:id).sort
873 960
874 961 # "is not" operator should include issues without target version
875 962 query = IssueQuery.new(:name => '_')
876 963 query.filters = {filter_name => {:operator => '!', :values => ['open', 'closed', 'locked']}, "project_id" => {:operator => '=', :values => [1]}}
877 964 assert_equal [1, 3, 7, 8], find_issues_with_query(query).map(&:id).uniq.sort
878 965 end
879 966
880 967 def test_filter_on_version_custom_field
881 968 field = IssueCustomField.generate!(:field_format => 'version', :is_filter => true)
882 969 issue = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {field.id.to_s => '2'})
883 970
884 971 query = IssueQuery.new(:name => '_')
885 972 filter_name = "cf_#{field.id}"
886 973 assert_include filter_name, query.available_filters.keys
887 974
888 975 query.filters = {filter_name => {:operator => '=', :values => ['2']}}
889 976 issues = find_issues_with_query(query)
890 977 assert_equal [issue.id], issues.map(&:id).sort
891 978 end
892 979
893 980 def test_filter_on_attribute_of_version_custom_field
894 981 field = IssueCustomField.generate!(:field_format => 'version', :is_filter => true)
895 982 version = Version.generate!(:effective_date => '2017-01-14')
896 983 issue = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {field.id.to_s => version.id.to_s})
897 984
898 985 query = IssueQuery.new(:name => '_')
899 986 filter_name = "cf_#{field.id}.due_date"
900 987 assert_include filter_name, query.available_filters.keys
901 988
902 989 query.filters = {filter_name => {:operator => '=', :values => ['2017-01-14']}}
903 990 issues = find_issues_with_query(query)
904 991 assert_equal [issue.id], issues.map(&:id).sort
905 992 end
906 993
907 994 def test_filter_on_custom_field_of_version_custom_field
908 995 field = IssueCustomField.generate!(:field_format => 'version', :is_filter => true)
909 996 attr = VersionCustomField.generate!(:field_format => 'string', :is_filter => true)
910 997
911 998 version = Version.generate!(:custom_field_values => {attr.id.to_s => 'ABC'})
912 999 issue = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {field.id.to_s => version.id.to_s})
913 1000
914 1001 query = IssueQuery.new(:name => '_')
915 1002 filter_name = "cf_#{field.id}.cf_#{attr.id}"
916 1003 assert_include filter_name, query.available_filters.keys
917 1004
918 1005 query.filters = {filter_name => {:operator => '=', :values => ['ABC']}}
919 1006 issues = find_issues_with_query(query)
920 1007 assert_equal [issue.id], issues.map(&:id).sort
921 1008 end
922 1009
923 1010 def test_filter_on_relations_with_a_specific_issue
924 1011 IssueRelation.delete_all
925 1012 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(2))
926 1013 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(3), :issue_to => Issue.find(1))
927 1014
928 1015 query = IssueQuery.new(:name => '_')
929 1016 query.filters = {"relates" => {:operator => '=', :values => ['1']}}
930 1017 assert_equal [2, 3], find_issues_with_query(query).map(&:id).sort
931 1018
932 1019 query = IssueQuery.new(:name => '_')
933 1020 query.filters = {"relates" => {:operator => '=', :values => ['2']}}
934 1021 assert_equal [1], find_issues_with_query(query).map(&:id).sort
935 1022 end
936 1023
937 1024 def test_filter_on_relations_with_any_issues_in_a_project
938 1025 IssueRelation.delete_all
939 1026 with_settings :cross_project_issue_relations => '1' do
940 1027 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(2).issues.first)
941 1028 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(2), :issue_to => Project.find(2).issues.first)
942 1029 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(3).issues.first)
943 1030 end
944 1031
945 1032 query = IssueQuery.new(:name => '_')
946 1033 query.filters = {"relates" => {:operator => '=p', :values => ['2']}}
947 1034 assert_equal [1, 2], find_issues_with_query(query).map(&:id).sort
948 1035
949 1036 query = IssueQuery.new(:name => '_')
950 1037 query.filters = {"relates" => {:operator => '=p', :values => ['3']}}
951 1038 assert_equal [1], find_issues_with_query(query).map(&:id).sort
952 1039
953 1040 query = IssueQuery.new(:name => '_')
954 1041 query.filters = {"relates" => {:operator => '=p', :values => ['4']}}
955 1042 assert_equal [], find_issues_with_query(query).map(&:id).sort
956 1043 end
957 1044
958 1045 def test_filter_on_relations_with_any_issues_not_in_a_project
959 1046 IssueRelation.delete_all
960 1047 with_settings :cross_project_issue_relations => '1' do
961 1048 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(2).issues.first)
962 1049 #IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(2), :issue_to => Project.find(1).issues.first)
963 1050 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(3).issues.first)
964 1051 end
965 1052
966 1053 query = IssueQuery.new(:name => '_')
967 1054 query.filters = {"relates" => {:operator => '=!p', :values => ['1']}}
968 1055 assert_equal [1], find_issues_with_query(query).map(&:id).sort
969 1056 end
970 1057
971 1058 def test_filter_on_relations_with_no_issues_in_a_project
972 1059 IssueRelation.delete_all
973 1060 with_settings :cross_project_issue_relations => '1' do
974 1061 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(2).issues.first)
975 1062 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(2), :issue_to => Project.find(3).issues.first)
976 1063 IssueRelation.create!(:relation_type => "relates", :issue_to => Project.find(2).issues.first, :issue_from => Issue.find(3))
977 1064 end
978 1065
979 1066 query = IssueQuery.new(:name => '_')
980 1067 query.filters = {"relates" => {:operator => '!p', :values => ['2']}}
981 1068 ids = find_issues_with_query(query).map(&:id).sort
982 1069 assert_include 2, ids
983 1070 assert_not_include 1, ids
984 1071 assert_not_include 3, ids
985 1072 end
986 1073
987 1074 def test_filter_on_relations_with_any_open_issues
988 1075 IssueRelation.delete_all
989 1076 # Issue 1 is blocked by 8, which is closed
990 1077 IssueRelation.create!(:relation_type => "blocked", :issue_from => Issue.find(1), :issue_to => Issue.find(8))
991 1078 # Issue 2 is blocked by 3, which is open
992 1079 IssueRelation.create!(:relation_type => "blocked", :issue_from => Issue.find(2), :issue_to => Issue.find(3))
993 1080
994 1081 query = IssueQuery.new(:name => '_')
995 1082 query.filters = {"blocked" => {:operator => "*o", :values => ['']}}
996 1083 ids = find_issues_with_query(query).map(&:id)
997 1084 assert_equal [], ids & [1]
998 1085 assert_include 2, ids
999 1086 end
1000 1087
1001 1088 def test_filter_on_relations_with_no_open_issues
1002 1089 IssueRelation.delete_all
1003 1090 # Issue 1 is blocked by 8, which is closed
1004 1091 IssueRelation.create!(:relation_type => "blocked", :issue_from => Issue.find(1), :issue_to => Issue.find(8))
1005 1092 # Issue 2 is blocked by 3, which is open
1006 1093 IssueRelation.create!(:relation_type => "blocked", :issue_from => Issue.find(2), :issue_to => Issue.find(3))
1007 1094
1008 1095 query = IssueQuery.new(:name => '_')
1009 1096 query.filters = {"blocked" => {:operator => "!o", :values => ['']}}
1010 1097 ids = find_issues_with_query(query).map(&:id)
1011 1098 assert_equal [], ids & [2]
1012 1099 assert_include 1, ids
1013 1100 end
1014 1101
1015 1102 def test_filter_on_relations_with_no_issues
1016 1103 IssueRelation.delete_all
1017 1104 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(2))
1018 1105 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(3), :issue_to => Issue.find(1))
1019 1106
1020 1107 query = IssueQuery.new(:name => '_')
1021 1108 query.filters = {"relates" => {:operator => '!*', :values => ['']}}
1022 1109 ids = find_issues_with_query(query).map(&:id)
1023 1110 assert_equal [], ids & [1, 2, 3]
1024 1111 assert_include 4, ids
1025 1112 end
1026 1113
1027 1114 def test_filter_on_relations_with_any_issues
1028 1115 IssueRelation.delete_all
1029 1116 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(2))
1030 1117 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(3), :issue_to => Issue.find(1))
1031 1118
1032 1119 query = IssueQuery.new(:name => '_')
1033 1120 query.filters = {"relates" => {:operator => '*', :values => ['']}}
1034 1121 assert_equal [1, 2, 3], find_issues_with_query(query).map(&:id).sort
1035 1122 end
1036 1123
1037 1124 def test_filter_on_relations_should_not_ignore_other_filter
1038 1125 issue = Issue.generate!
1039 1126 issue1 = Issue.generate!(:status_id => 1)
1040 1127 issue2 = Issue.generate!(:status_id => 2)
1041 1128 IssueRelation.create!(:relation_type => "relates", :issue_from => issue, :issue_to => issue1)
1042 1129 IssueRelation.create!(:relation_type => "relates", :issue_from => issue, :issue_to => issue2)
1043 1130
1044 1131 query = IssueQuery.new(:name => '_')
1045 1132 query.filters = {
1046 1133 "status_id" => {:operator => '=', :values => ['1']},
1047 1134 "relates" => {:operator => '=', :values => [issue.id.to_s]}
1048 1135 }
1049 1136 assert_equal [issue1], find_issues_with_query(query)
1050 1137 end
1051 1138
1052 1139 def test_filter_on_parent
1053 1140 Issue.delete_all
1054 1141 parent = Issue.generate_with_descendants!
1055 1142
1056 1143
1057 1144 query = IssueQuery.new(:name => '_')
1058 1145 query.filters = {"parent_id" => {:operator => '=', :values => [parent.id.to_s]}}
1059 1146 assert_equal parent.children.map(&:id).sort, find_issues_with_query(query).map(&:id).sort
1060 1147
1061 1148 query.filters = {"parent_id" => {:operator => '~', :values => [parent.id.to_s]}}
1062 1149 assert_equal parent.descendants.map(&:id).sort, find_issues_with_query(query).map(&:id).sort
1063 1150
1064 1151 query.filters = {"parent_id" => {:operator => '*', :values => ['']}}
1065 1152 assert_equal parent.descendants.map(&:id).sort, find_issues_with_query(query).map(&:id).sort
1066 1153
1067 1154 query.filters = {"parent_id" => {:operator => '!*', :values => ['']}}
1068 1155 assert_equal [parent.id], find_issues_with_query(query).map(&:id).sort
1069 1156 end
1070 1157
1071 1158 def test_filter_on_invalid_parent_should_return_no_results
1072 1159 query = IssueQuery.new(:name => '_')
1073 1160 query.filters = {"parent_id" => {:operator => '=', :values => '99999999999'}}
1074 1161 assert_equal [], find_issues_with_query(query).map(&:id).sort
1075 1162
1076 1163 query.filters = {"parent_id" => {:operator => '~', :values => '99999999999'}}
1077 1164 assert_equal [], find_issues_with_query(query)
1078 1165 end
1079 1166
1080 1167 def test_filter_on_child
1081 1168 Issue.delete_all
1082 1169 parent = Issue.generate_with_descendants!
1083 1170 child, leaf = parent.children.sort_by(&:id)
1084 1171 grandchild = child.children.first
1085 1172
1086 1173
1087 1174 query = IssueQuery.new(:name => '_')
1088 1175 query.filters = {"child_id" => {:operator => '=', :values => [grandchild.id.to_s]}}
1089 1176 assert_equal [child.id], find_issues_with_query(query).map(&:id).sort
1090 1177
1091 1178 query.filters = {"child_id" => {:operator => '~', :values => [grandchild.id.to_s]}}
1092 1179 assert_equal [parent, child].map(&:id).sort, find_issues_with_query(query).map(&:id).sort
1093 1180
1094 1181 query.filters = {"child_id" => {:operator => '*', :values => ['']}}
1095 1182 assert_equal [parent, child].map(&:id).sort, find_issues_with_query(query).map(&:id).sort
1096 1183
1097 1184 query.filters = {"child_id" => {:operator => '!*', :values => ['']}}
1098 1185 assert_equal [grandchild, leaf].map(&:id).sort, find_issues_with_query(query).map(&:id).sort
1099 1186 end
1100 1187
1101 1188 def test_filter_on_invalid_child_should_return_no_results
1102 1189 query = IssueQuery.new(:name => '_')
1103 1190 query.filters = {"child_id" => {:operator => '=', :values => '99999999999'}}
1104 1191 assert_equal [], find_issues_with_query(query)
1105 1192
1106 1193 query.filters = {"child_id" => {:operator => '~', :values => '99999999999'}}
1107 1194 assert_equal [].map(&:id).sort, find_issues_with_query(query)
1108 1195 end
1109 1196
1110 1197 def test_statement_should_be_nil_with_no_filters
1111 1198 q = IssueQuery.new(:name => '_')
1112 1199 q.filters = {}
1113 1200
1114 1201 assert q.valid?
1115 1202 assert_nil q.statement
1116 1203 end
1117 1204
1118 1205 def test_available_filters_as_json_should_include_missing_assigned_to_id_values
1119 1206 user = User.generate!
1120 1207 with_current_user User.find(1) do
1121 1208 q = IssueQuery.new
1122 1209 q.filters = {"assigned_to_id" => {:operator => '=', :values => user.id.to_s}}
1123 1210
1124 1211 filters = q.available_filters_as_json
1125 1212 assert_include [user.name, user.id.to_s], filters['assigned_to_id']['values']
1126 1213 end
1127 1214 end
1128 1215
1129 1216 def test_available_filters_as_json_should_include_missing_author_id_values
1130 1217 user = User.generate!
1131 1218 with_current_user User.find(1) do
1132 1219 q = IssueQuery.new
1133 1220 q.filters = {"author_id" => {:operator => '=', :values => user.id.to_s}}
1134 1221
1135 1222 filters = q.available_filters_as_json
1136 1223 assert_include [user.name, user.id.to_s], filters['author_id']['values']
1137 1224 end
1138 1225 end
1139 1226
1140 1227 def test_default_columns
1141 1228 q = IssueQuery.new
1142 1229 assert q.columns.any?
1143 1230 assert q.inline_columns.any?
1144 1231 assert q.block_columns.empty?
1145 1232 end
1146 1233
1147 1234 def test_set_column_names
1148 1235 q = IssueQuery.new
1149 1236 q.column_names = ['tracker', :subject, '', 'unknonw_column']
1150 1237 assert_equal [:id, :tracker, :subject], q.columns.collect {|c| c.name}
1151 1238 end
1152 1239
1153 1240 def test_has_column_should_accept_a_column_name
1154 1241 q = IssueQuery.new
1155 1242 q.column_names = ['tracker', :subject]
1156 1243 assert q.has_column?(:tracker)
1157 1244 assert !q.has_column?(:category)
1158 1245 end
1159 1246
1160 1247 def test_has_column_should_accept_a_column
1161 1248 q = IssueQuery.new
1162 1249 q.column_names = ['tracker', :subject]
1163 1250
1164 1251 tracker_column = q.available_columns.detect {|c| c.name==:tracker}
1165 1252 assert_kind_of QueryColumn, tracker_column
1166 1253 category_column = q.available_columns.detect {|c| c.name==:category}
1167 1254 assert_kind_of QueryColumn, category_column
1168 1255
1169 1256 assert q.has_column?(tracker_column)
1170 1257 assert !q.has_column?(category_column)
1171 1258 end
1172 1259
1173 1260 def test_has_column_should_return_true_for_default_column
1174 1261 with_settings :issue_list_default_columns => %w(tracker subject) do
1175 1262 q = IssueQuery.new
1176 1263 assert q.has_column?(:tracker)
1177 1264 assert !q.has_column?(:category)
1178 1265 end
1179 1266 end
1180 1267
1181 1268 def test_inline_and_block_columns
1182 1269 q = IssueQuery.new
1183 1270 q.column_names = ['subject', 'description', 'tracker']
1184 1271
1185 1272 assert_equal [:id, :subject, :tracker], q.inline_columns.map(&:name)
1186 1273 assert_equal [:description], q.block_columns.map(&:name)
1187 1274 end
1188 1275
1189 1276 def test_custom_field_columns_should_be_inline
1190 1277 q = IssueQuery.new
1191 1278 columns = q.available_columns.select {|column| column.is_a? QueryCustomFieldColumn}
1192 1279 assert columns.any?
1193 1280 assert_nil columns.detect {|column| !column.inline?}
1194 1281 end
1195 1282
1196 1283 def test_query_should_preload_spent_hours
1197 1284 q = IssueQuery.new(:name => '_', :column_names => [:subject, :spent_hours])
1198 1285 assert q.has_column?(:spent_hours)
1199 1286 issues = q.issues
1200 1287 assert_not_nil issues.first.instance_variable_get("@spent_hours")
1201 1288 end
1202 1289
1203 1290 def test_groupable_columns_should_include_custom_fields
1204 1291 q = IssueQuery.new
1205 1292 column = q.groupable_columns.detect {|c| c.name == :cf_1}
1206 1293 assert_not_nil column
1207 1294 assert_kind_of QueryCustomFieldColumn, column
1208 1295 end
1209 1296
1210 1297 def test_groupable_columns_should_not_include_multi_custom_fields
1211 1298 field = CustomField.find(1)
1212 1299 field.update_attribute :multiple, true
1213 1300
1214 1301 q = IssueQuery.new
1215 1302 column = q.groupable_columns.detect {|c| c.name == :cf_1}
1216 1303 assert_nil column
1217 1304 end
1218 1305
1219 1306 def test_groupable_columns_should_include_user_custom_fields
1220 1307 cf = IssueCustomField.create!(:name => 'User', :is_for_all => true, :tracker_ids => [1], :field_format => 'user')
1221 1308
1222 1309 q = IssueQuery.new
1223 1310 assert q.groupable_columns.detect {|c| c.name == "cf_#{cf.id}".to_sym}
1224 1311 end
1225 1312
1226 1313 def test_groupable_columns_should_include_version_custom_fields
1227 1314 cf = IssueCustomField.create!(:name => 'User', :is_for_all => true, :tracker_ids => [1], :field_format => 'version')
1228 1315
1229 1316 q = IssueQuery.new
1230 1317 assert q.groupable_columns.detect {|c| c.name == "cf_#{cf.id}".to_sym}
1231 1318 end
1232 1319
1233 1320 def test_grouped_with_valid_column
1234 1321 q = IssueQuery.new(:group_by => 'status')
1235 1322 assert q.grouped?
1236 1323 assert_not_nil q.group_by_column
1237 1324 assert_equal :status, q.group_by_column.name
1238 1325 assert_not_nil q.group_by_statement
1239 1326 assert_equal 'status', q.group_by_statement
1240 1327 end
1241 1328
1242 1329 def test_grouped_with_invalid_column
1243 1330 q = IssueQuery.new(:group_by => 'foo')
1244 1331 assert !q.grouped?
1245 1332 assert_nil q.group_by_column
1246 1333 assert_nil q.group_by_statement
1247 1334 end
1248 1335
1249 1336 def test_sortable_columns_should_sort_assignees_according_to_user_format_setting
1250 1337 with_settings :user_format => 'lastname_comma_firstname' do
1251 1338 q = IssueQuery.new
1252 1339 assert q.sortable_columns.has_key?('assigned_to')
1253 1340 assert_equal %w(users.lastname users.firstname users.id), q.sortable_columns['assigned_to']
1254 1341 end
1255 1342 end
1256 1343
1257 1344 def test_sortable_columns_should_sort_authors_according_to_user_format_setting
1258 1345 with_settings :user_format => 'lastname_comma_firstname' do
1259 1346 q = IssueQuery.new
1260 1347 assert q.sortable_columns.has_key?('author')
1261 1348 assert_equal %w(authors.lastname authors.firstname authors.id), q.sortable_columns['author']
1262 1349 end
1263 1350 end
1264 1351
1265 1352 def test_sortable_columns_should_include_custom_field
1266 1353 q = IssueQuery.new
1267 1354 assert q.sortable_columns['cf_1']
1268 1355 end
1269 1356
1270 1357 def test_sortable_columns_should_not_include_multi_custom_field
1271 1358 field = CustomField.find(1)
1272 1359 field.update_attribute :multiple, true
1273 1360
1274 1361 q = IssueQuery.new
1275 1362 assert !q.sortable_columns['cf_1']
1276 1363 end
1277 1364
1278 1365 def test_default_sort
1279 1366 q = IssueQuery.new
1280 1367 assert_equal [], q.sort_criteria
1281 1368 end
1282 1369
1283 1370 def test_set_sort_criteria_with_hash
1284 1371 q = IssueQuery.new
1285 1372 q.sort_criteria = {'0' => ['priority', 'desc'], '2' => ['tracker']}
1286 1373 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
1287 1374 end
1288 1375
1289 1376 def test_set_sort_criteria_with_array
1290 1377 q = IssueQuery.new
1291 1378 q.sort_criteria = [['priority', 'desc'], 'tracker']
1292 1379 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
1293 1380 end
1294 1381
1295 1382 def test_create_query_with_sort
1296 1383 q = IssueQuery.new(:name => 'Sorted')
1297 1384 q.sort_criteria = [['priority', 'desc'], 'tracker']
1298 1385 assert q.save
1299 1386 q.reload
1300 1387 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
1301 1388 end
1302 1389
1303 1390 def test_sort_by_string_custom_field_asc
1304 1391 q = IssueQuery.new
1305 1392 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'string' }
1306 1393 assert c
1307 1394 assert c.sortable
1308 1395 issues = q.issues(:order => "#{c.sortable} ASC")
1309 1396 values = issues.collect {|i| i.custom_value_for(c.custom_field).to_s}
1310 1397 assert !values.empty?
1311 1398 assert_equal values.sort, values
1312 1399 end
1313 1400
1314 1401 def test_sort_by_string_custom_field_desc
1315 1402 q = IssueQuery.new
1316 1403 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'string' }
1317 1404 assert c
1318 1405 assert c.sortable
1319 1406 issues = q.issues(:order => "#{c.sortable} DESC")
1320 1407 values = issues.collect {|i| i.custom_value_for(c.custom_field).to_s}
1321 1408 assert !values.empty?
1322 1409 assert_equal values.sort.reverse, values
1323 1410 end
1324 1411
1325 1412 def test_sort_by_float_custom_field_asc
1326 1413 q = IssueQuery.new
1327 1414 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'float' }
1328 1415 assert c
1329 1416 assert c.sortable
1330 1417 issues = q.issues(:order => "#{c.sortable} ASC")
1331 1418 values = issues.collect {|i| begin; Kernel.Float(i.custom_value_for(c.custom_field).to_s); rescue; nil; end}.compact
1332 1419 assert !values.empty?
1333 1420 assert_equal values.sort, values
1334 1421 end
1335 1422
1336 1423 def test_set_totalable_names
1337 1424 q = IssueQuery.new
1338 1425 q.totalable_names = ['estimated_hours', :spent_hours, '']
1339 1426 assert_equal [:estimated_hours, :spent_hours], q.totalable_columns.map(&:name)
1340 1427 end
1341 1428
1342 1429 def test_totalable_columns_should_default_to_settings
1343 1430 with_settings :issue_list_default_totals => ['estimated_hours'] do
1344 1431 q = IssueQuery.new
1345 1432 assert_equal [:estimated_hours], q.totalable_columns.map(&:name)
1346 1433 end
1347 1434 end
1348 1435
1349 1436 def test_available_totalable_columns_should_include_estimated_hours
1350 1437 q = IssueQuery.new
1351 1438 assert_include :estimated_hours, q.available_totalable_columns.map(&:name)
1352 1439 end
1353 1440
1354 1441 def test_available_totalable_columns_should_include_spent_hours
1355 1442 User.current = User.find(1)
1356 1443
1357 1444 q = IssueQuery.new
1358 1445 assert_include :spent_hours, q.available_totalable_columns.map(&:name)
1359 1446 end
1360 1447
1361 1448 def test_available_totalable_columns_should_include_int_custom_field
1362 1449 field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
1363 1450 q = IssueQuery.new
1364 1451 assert_include "cf_#{field.id}".to_sym, q.available_totalable_columns.map(&:name)
1365 1452 end
1366 1453
1367 1454 def test_available_totalable_columns_should_include_float_custom_field
1368 1455 field = IssueCustomField.generate!(:field_format => 'float', :is_for_all => true)
1369 1456 q = IssueQuery.new
1370 1457 assert_include "cf_#{field.id}".to_sym, q.available_totalable_columns.map(&:name)
1371 1458 end
1372 1459
1373 1460 def test_total_for_estimated_hours
1374 1461 Issue.delete_all
1375 1462 Issue.generate!(:estimated_hours => 5.5)
1376 1463 Issue.generate!(:estimated_hours => 1.1)
1377 1464 Issue.generate!
1378 1465
1379 1466 q = IssueQuery.new
1380 1467 assert_equal 6.6, q.total_for(:estimated_hours)
1381 1468 end
1382 1469
1383 1470 def test_total_by_group_for_estimated_hours
1384 1471 Issue.delete_all
1385 1472 Issue.generate!(:estimated_hours => 5.5, :assigned_to_id => 2)
1386 1473 Issue.generate!(:estimated_hours => 1.1, :assigned_to_id => 3)
1387 1474 Issue.generate!(:estimated_hours => 3.5)
1388 1475
1389 1476 q = IssueQuery.new(:group_by => 'assigned_to')
1390 1477 assert_equal(
1391 1478 {nil => 3.5, User.find(2) => 5.5, User.find(3) => 1.1},
1392 1479 q.total_by_group_for(:estimated_hours)
1393 1480 )
1394 1481 end
1395 1482
1396 1483 def test_total_for_spent_hours
1397 1484 TimeEntry.delete_all
1398 1485 TimeEntry.generate!(:hours => 5.5)
1399 1486 TimeEntry.generate!(:hours => 1.1)
1400 1487
1401 1488 q = IssueQuery.new
1402 1489 assert_equal 6.6, q.total_for(:spent_hours)
1403 1490 end
1404 1491
1405 1492 def test_total_by_group_for_spent_hours
1406 1493 TimeEntry.delete_all
1407 1494 TimeEntry.generate!(:hours => 5.5, :issue_id => 1)
1408 1495 TimeEntry.generate!(:hours => 1.1, :issue_id => 2)
1409 1496 Issue.where(:id => 1).update_all(:assigned_to_id => 2)
1410 1497 Issue.where(:id => 2).update_all(:assigned_to_id => 3)
1411 1498
1412 1499 q = IssueQuery.new(:group_by => 'assigned_to')
1413 1500 assert_equal(
1414 1501 {User.find(2) => 5.5, User.find(3) => 1.1},
1415 1502 q.total_by_group_for(:spent_hours)
1416 1503 )
1417 1504 end
1418 1505
1419 1506 def test_total_by_project_group_for_spent_hours
1420 1507 TimeEntry.delete_all
1421 1508 TimeEntry.generate!(:hours => 5.5, :issue_id => 1)
1422 1509 TimeEntry.generate!(:hours => 1.1, :issue_id => 2)
1423 1510 Issue.where(:id => 1).update_all(:assigned_to_id => 2)
1424 1511 Issue.where(:id => 2).update_all(:assigned_to_id => 3)
1425 1512
1426 1513 q = IssueQuery.new(:group_by => 'project')
1427 1514 assert_equal(
1428 1515 {Project.find(1) => 6.6},
1429 1516 q.total_by_group_for(:spent_hours)
1430 1517 )
1431 1518 end
1432 1519
1433 1520 def test_total_for_int_custom_field
1434 1521 field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
1435 1522 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2')
1436 1523 CustomValue.create!(:customized => Issue.find(2), :custom_field => field, :value => '7')
1437 1524 CustomValue.create!(:customized => Issue.find(3), :custom_field => field, :value => '')
1438 1525
1439 1526 q = IssueQuery.new
1440 1527 assert_equal 9, q.total_for("cf_#{field.id}")
1441 1528 end
1442 1529
1443 1530 def test_total_by_group_for_int_custom_field
1444 1531 field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true)
1445 1532 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2')
1446 1533 CustomValue.create!(:customized => Issue.find(2), :custom_field => field, :value => '7')
1447 1534 Issue.where(:id => 1).update_all(:assigned_to_id => 2)
1448 1535 Issue.where(:id => 2).update_all(:assigned_to_id => 3)
1449 1536
1450 1537 q = IssueQuery.new(:group_by => 'assigned_to')
1451 1538 assert_equal(
1452 1539 {User.find(2) => 2, User.find(3) => 7},
1453 1540 q.total_by_group_for("cf_#{field.id}")
1454 1541 )
1455 1542 end
1456 1543
1457 1544 def test_total_for_float_custom_field
1458 1545 field = IssueCustomField.generate!(:field_format => 'float', :is_for_all => true)
1459 1546 CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2.3')
1460 1547 CustomValue.create!(:customized => Issue.find(2), :custom_field => field, :value => '7')
1461 1548 CustomValue.create!(:customized => Issue.find(3), :custom_field => field, :value => '')
1462 1549
1463 1550 q = IssueQuery.new
1464 1551 assert_equal 9.3, q.total_for("cf_#{field.id}")
1465 1552 end
1466 1553
1467 1554 def test_invalid_query_should_raise_query_statement_invalid_error
1468 1555 q = IssueQuery.new
1469 1556 assert_raise Query::StatementInvalid do
1470 1557 q.issues(:conditions => "foo = 1")
1471 1558 end
1472 1559 end
1473 1560
1474 1561 def test_issue_count
1475 1562 q = IssueQuery.new(:name => '_')
1476 1563 issue_count = q.issue_count
1477 1564 assert_equal q.issues.size, issue_count
1478 1565 end
1479 1566
1480 1567 def test_issue_count_with_archived_issues
1481 1568 p = Project.generate! do |project|
1482 1569 project.status = Project::STATUS_ARCHIVED
1483 1570 end
1484 1571 i = Issue.generate!( :project => p, :tracker => p.trackers.first )
1485 1572 assert !i.visible?
1486 1573
1487 1574 test_issue_count
1488 1575 end
1489 1576
1490 1577 def test_issue_count_by_association_group
1491 1578 q = IssueQuery.new(:name => '_', :group_by => 'assigned_to')
1492 1579 count_by_group = q.issue_count_by_group
1493 1580 assert_kind_of Hash, count_by_group
1494 1581 assert_equal %w(NilClass User), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
1495 1582 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
1496 1583 assert count_by_group.has_key?(User.find(3))
1497 1584 end
1498 1585
1499 1586 def test_issue_count_by_list_custom_field_group
1500 1587 q = IssueQuery.new(:name => '_', :group_by => 'cf_1')
1501 1588 count_by_group = q.issue_count_by_group
1502 1589 assert_kind_of Hash, count_by_group
1503 1590 assert_equal %w(NilClass String), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
1504 1591 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
1505 1592 assert count_by_group.has_key?('MySQL')
1506 1593 end
1507 1594
1508 1595 def test_issue_count_by_date_custom_field_group
1509 1596 q = IssueQuery.new(:name => '_', :group_by => 'cf_8')
1510 1597 count_by_group = q.issue_count_by_group
1511 1598 assert_kind_of Hash, count_by_group
1512 1599 assert_equal %w(Date NilClass), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
1513 1600 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
1514 1601 end
1515 1602
1516 1603 def test_issue_count_with_nil_group_only
1517 1604 Issue.update_all("assigned_to_id = NULL")
1518 1605
1519 1606 q = IssueQuery.new(:name => '_', :group_by => 'assigned_to')
1520 1607 count_by_group = q.issue_count_by_group
1521 1608 assert_kind_of Hash, count_by_group
1522 1609 assert_equal 1, count_by_group.keys.size
1523 1610 assert_nil count_by_group.keys.first
1524 1611 end
1525 1612
1526 1613 def test_issue_ids
1527 1614 q = IssueQuery.new(:name => '_')
1528 1615 order = "issues.subject, issues.id"
1529 1616 issues = q.issues(:order => order)
1530 1617 assert_equal issues.map(&:id), q.issue_ids(:order => order)
1531 1618 end
1532 1619
1533 1620 def test_label_for
1534 1621 set_language_if_valid 'en'
1535 1622 q = IssueQuery.new
1536 1623 assert_equal 'Assignee', q.label_for('assigned_to_id')
1537 1624 end
1538 1625
1539 1626 def test_label_for_fr
1540 1627 set_language_if_valid 'fr'
1541 1628 q = IssueQuery.new
1542 1629 assert_equal "Assign\xc3\xa9 \xc3\xa0".force_encoding('UTF-8'), q.label_for('assigned_to_id')
1543 1630 end
1544 1631
1545 1632 def test_editable_by
1546 1633 admin = User.find(1)
1547 1634 manager = User.find(2)
1548 1635 developer = User.find(3)
1549 1636
1550 1637 # Public query on project 1
1551 1638 q = IssueQuery.find(1)
1552 1639 assert q.editable_by?(admin)
1553 1640 assert q.editable_by?(manager)
1554 1641 assert !q.editable_by?(developer)
1555 1642
1556 1643 # Private query on project 1
1557 1644 q = IssueQuery.find(2)
1558 1645 assert q.editable_by?(admin)
1559 1646 assert !q.editable_by?(manager)
1560 1647 assert q.editable_by?(developer)
1561 1648
1562 1649 # Private query for all projects
1563 1650 q = IssueQuery.find(3)
1564 1651 assert q.editable_by?(admin)
1565 1652 assert !q.editable_by?(manager)
1566 1653 assert q.editable_by?(developer)
1567 1654
1568 1655 # Public query for all projects
1569 1656 q = IssueQuery.find(4)
1570 1657 assert q.editable_by?(admin)
1571 1658 assert !q.editable_by?(manager)
1572 1659 assert !q.editable_by?(developer)
1573 1660 end
1574 1661
1575 1662 def test_visible_scope
1576 1663 query_ids = IssueQuery.visible(User.anonymous).map(&:id)
1577 1664
1578 1665 assert query_ids.include?(1), 'public query on public project was not visible'
1579 1666 assert query_ids.include?(4), 'public query for all projects was not visible'
1580 1667 assert !query_ids.include?(2), 'private query on public project was visible'
1581 1668 assert !query_ids.include?(3), 'private query for all projects was visible'
1582 1669 assert !query_ids.include?(7), 'public query on private project was visible'
1583 1670 end
1584 1671
1585 1672 def test_query_with_public_visibility_should_be_visible_to_anyone
1586 1673 q = IssueQuery.create!(:name => 'Query', :visibility => IssueQuery::VISIBILITY_PUBLIC)
1587 1674
1588 1675 assert q.visible?(User.anonymous)
1589 1676 assert IssueQuery.visible(User.anonymous).find_by_id(q.id)
1590 1677
1591 1678 assert q.visible?(User.find(7))
1592 1679 assert IssueQuery.visible(User.find(7)).find_by_id(q.id)
1593 1680
1594 1681 assert q.visible?(User.find(2))
1595 1682 assert IssueQuery.visible(User.find(2)).find_by_id(q.id)
1596 1683
1597 1684 assert q.visible?(User.find(1))
1598 1685 assert IssueQuery.visible(User.find(1)).find_by_id(q.id)
1599 1686 end
1600 1687
1601 1688 def test_query_with_roles_visibility_should_be_visible_to_user_with_role
1602 1689 q = IssueQuery.create!(:name => 'Query', :visibility => IssueQuery::VISIBILITY_ROLES, :role_ids => [1,2])
1603 1690
1604 1691 assert !q.visible?(User.anonymous)
1605 1692 assert_nil IssueQuery.visible(User.anonymous).find_by_id(q.id)
1606 1693
1607 1694 assert !q.visible?(User.find(7))
1608 1695 assert_nil IssueQuery.visible(User.find(7)).find_by_id(q.id)
1609 1696
1610 1697 assert q.visible?(User.find(2))
1611 1698 assert IssueQuery.visible(User.find(2)).find_by_id(q.id)
1612 1699
1613 1700 assert q.visible?(User.find(1))
1614 1701 assert IssueQuery.visible(User.find(1)).find_by_id(q.id)
1615 1702 end
1616 1703
1617 1704 def test_query_with_private_visibility_should_be_visible_to_owner
1618 1705 q = IssueQuery.create!(:name => 'Query', :visibility => IssueQuery::VISIBILITY_PRIVATE, :user => User.find(7))
1619 1706
1620 1707 assert !q.visible?(User.anonymous)
1621 1708 assert_nil IssueQuery.visible(User.anonymous).find_by_id(q.id)
1622 1709
1623 1710 assert q.visible?(User.find(7))
1624 1711 assert IssueQuery.visible(User.find(7)).find_by_id(q.id)
1625 1712
1626 1713 assert !q.visible?(User.find(2))
1627 1714 assert_nil IssueQuery.visible(User.find(2)).find_by_id(q.id)
1628 1715
1629 1716 assert q.visible?(User.find(1))
1630 1717 assert_nil IssueQuery.visible(User.find(1)).find_by_id(q.id)
1631 1718 end
1632 1719
1633 1720 test "#available_filters should include users of visible projects in cross-project view" do
1634 1721 users = IssueQuery.new.available_filters["assigned_to_id"]
1635 1722 assert_not_nil users
1636 1723 assert users[:values].map{|u|u[1]}.include?("3")
1637 1724 end
1638 1725
1639 1726 test "#available_filters should include users of subprojects" do
1640 1727 user1 = User.generate!
1641 1728 user2 = User.generate!
1642 1729 project = Project.find(1)
1643 1730 Member.create!(:principal => user1, :project => project.children.visible.first, :role_ids => [1])
1644 1731
1645 1732 users = IssueQuery.new(:project => project).available_filters["assigned_to_id"]
1646 1733 assert_not_nil users
1647 1734 assert users[:values].map{|u|u[1]}.include?(user1.id.to_s)
1648 1735 assert !users[:values].map{|u|u[1]}.include?(user2.id.to_s)
1649 1736 end
1650 1737
1651 1738 test "#available_filters should include visible projects in cross-project view" do
1652 1739 projects = IssueQuery.new.available_filters["project_id"]
1653 1740 assert_not_nil projects
1654 1741 assert projects[:values].map{|u|u[1]}.include?("1")
1655 1742 end
1656 1743
1657 1744 test "#available_filters should include 'member_of_group' filter" do
1658 1745 query = IssueQuery.new
1659 1746 assert query.available_filters.keys.include?("member_of_group")
1660 1747 assert_equal :list_optional, query.available_filters["member_of_group"][:type]
1661 1748 assert query.available_filters["member_of_group"][:values].present?
1662 1749 assert_equal Group.givable.sort.map {|g| [g.name, g.id.to_s]},
1663 1750 query.available_filters["member_of_group"][:values].sort
1664 1751 end
1665 1752
1666 1753 test "#available_filters should include 'assigned_to_role' filter" do
1667 1754 query = IssueQuery.new
1668 1755 assert query.available_filters.keys.include?("assigned_to_role")
1669 1756 assert_equal :list_optional, query.available_filters["assigned_to_role"][:type]
1670 1757
1671 1758 assert query.available_filters["assigned_to_role"][:values].include?(['Manager','1'])
1672 1759 assert query.available_filters["assigned_to_role"][:values].include?(['Developer','2'])
1673 1760 assert query.available_filters["assigned_to_role"][:values].include?(['Reporter','3'])
1674 1761
1675 1762 assert ! query.available_filters["assigned_to_role"][:values].include?(['Non member','4'])
1676 1763 assert ! query.available_filters["assigned_to_role"][:values].include?(['Anonymous','5'])
1677 1764 end
1678 1765
1679 1766 def test_available_filters_should_include_custom_field_according_to_user_visibility
1680 1767 visible_field = IssueCustomField.generate!(:is_for_all => true, :is_filter => true, :visible => true)
1681 1768 hidden_field = IssueCustomField.generate!(:is_for_all => true, :is_filter => true, :visible => false, :role_ids => [1])
1682 1769
1683 1770 with_current_user User.find(3) do
1684 1771 query = IssueQuery.new
1685 1772 assert_include "cf_#{visible_field.id}", query.available_filters.keys
1686 1773 assert_not_include "cf_#{hidden_field.id}", query.available_filters.keys
1687 1774 end
1688 1775 end
1689 1776
1690 1777 def test_available_columns_should_include_custom_field_according_to_user_visibility
1691 1778 visible_field = IssueCustomField.generate!(:is_for_all => true, :is_filter => true, :visible => true)
1692 1779 hidden_field = IssueCustomField.generate!(:is_for_all => true, :is_filter => true, :visible => false, :role_ids => [1])
1693 1780
1694 1781 with_current_user User.find(3) do
1695 1782 query = IssueQuery.new
1696 1783 assert_include :"cf_#{visible_field.id}", query.available_columns.map(&:name)
1697 1784 assert_not_include :"cf_#{hidden_field.id}", query.available_columns.map(&:name)
1698 1785 end
1699 1786 end
1700 1787
1701 1788 def setup_member_of_group
1702 1789 Group.destroy_all # No fixtures
1703 1790 @user_in_group = User.generate!
1704 1791 @second_user_in_group = User.generate!
1705 1792 @user_in_group2 = User.generate!
1706 1793 @user_not_in_group = User.generate!
1707 1794
1708 1795 @group = Group.generate!.reload
1709 1796 @group.users << @user_in_group
1710 1797 @group.users << @second_user_in_group
1711 1798
1712 1799 @group2 = Group.generate!.reload
1713 1800 @group2.users << @user_in_group2
1714 1801
1715 1802 @query = IssueQuery.new(:name => '_')
1716 1803 end
1717 1804
1718 1805 test "member_of_group filter should search assigned to for users in the group" do
1719 1806 setup_member_of_group
1720 1807 @query.add_filter('member_of_group', '=', [@group.id.to_s])
1721 1808
1722 1809 assert_find_issues_with_query_is_successful @query
1723 1810 end
1724 1811
1725 1812 test "member_of_group filter should search not assigned to any group member (none)" do
1726 1813 setup_member_of_group
1727 1814 @query.add_filter('member_of_group', '!*', [''])
1728 1815
1729 1816 assert_find_issues_with_query_is_successful @query
1730 1817 end
1731 1818
1732 1819 test "member_of_group filter should search assigned to any group member (all)" do
1733 1820 setup_member_of_group
1734 1821 @query.add_filter('member_of_group', '*', [''])
1735 1822
1736 1823 assert_find_issues_with_query_is_successful @query
1737 1824 end
1738 1825
1739 1826 test "member_of_group filter should return an empty set with = empty group" do
1740 1827 setup_member_of_group
1741 1828 @empty_group = Group.generate!
1742 1829 @query.add_filter('member_of_group', '=', [@empty_group.id.to_s])
1743 1830
1744 1831 assert_equal [], find_issues_with_query(@query)
1745 1832 end
1746 1833
1747 1834 test "member_of_group filter should return issues with ! empty group" do
1748 1835 setup_member_of_group
1749 1836 @empty_group = Group.generate!
1750 1837 @query.add_filter('member_of_group', '!', [@empty_group.id.to_s])
1751 1838
1752 1839 assert_find_issues_with_query_is_successful @query
1753 1840 end
1754 1841
1755 1842 def setup_assigned_to_role
1756 1843 @manager_role = Role.find_by_name('Manager')
1757 1844 @developer_role = Role.find_by_name('Developer')
1758 1845
1759 1846 @project = Project.generate!
1760 1847 @manager = User.generate!
1761 1848 @developer = User.generate!
1762 1849 @boss = User.generate!
1763 1850 @guest = User.generate!
1764 1851 User.add_to_project(@manager, @project, @manager_role)
1765 1852 User.add_to_project(@developer, @project, @developer_role)
1766 1853 User.add_to_project(@boss, @project, [@manager_role, @developer_role])
1767 1854
1768 1855 @issue1 = Issue.generate!(:project => @project, :assigned_to_id => @manager.id)
1769 1856 @issue2 = Issue.generate!(:project => @project, :assigned_to_id => @developer.id)
1770 1857 @issue3 = Issue.generate!(:project => @project, :assigned_to_id => @boss.id)
1771 1858 @issue4 = Issue.generate!(:project => @project, :author_id => @guest.id, :assigned_to_id => @guest.id)
1772 1859 @issue5 = Issue.generate!(:project => @project)
1773 1860
1774 1861 @query = IssueQuery.new(:name => '_', :project => @project)
1775 1862 end
1776 1863
1777 1864 test "assigned_to_role filter should search assigned to for users with the Role" do
1778 1865 setup_assigned_to_role
1779 1866 @query.add_filter('assigned_to_role', '=', [@manager_role.id.to_s])
1780 1867
1781 1868 assert_query_result [@issue1, @issue3], @query
1782 1869 end
1783 1870
1784 1871 test "assigned_to_role filter should search assigned to for users with the Role on the issue project" do
1785 1872 setup_assigned_to_role
1786 1873 other_project = Project.generate!
1787 1874 User.add_to_project(@developer, other_project, @manager_role)
1788 1875 @query.add_filter('assigned_to_role', '=', [@manager_role.id.to_s])
1789 1876
1790 1877 assert_query_result [@issue1, @issue3], @query
1791 1878 end
1792 1879
1793 1880 test "assigned_to_role filter should return an empty set with empty role" do
1794 1881 setup_assigned_to_role
1795 1882 @empty_role = Role.generate!
1796 1883 @query.add_filter('assigned_to_role', '=', [@empty_role.id.to_s])
1797 1884
1798 1885 assert_query_result [], @query
1799 1886 end
1800 1887
1801 1888 test "assigned_to_role filter should search assigned to for users without the Role" do
1802 1889 setup_assigned_to_role
1803 1890 @query.add_filter('assigned_to_role', '!', [@manager_role.id.to_s])
1804 1891
1805 1892 assert_query_result [@issue2, @issue4, @issue5], @query
1806 1893 end
1807 1894
1808 1895 test "assigned_to_role filter should search assigned to for users not assigned to any Role (none)" do
1809 1896 setup_assigned_to_role
1810 1897 @query.add_filter('assigned_to_role', '!*', [''])
1811 1898
1812 1899 assert_query_result [@issue4, @issue5], @query
1813 1900 end
1814 1901
1815 1902 test "assigned_to_role filter should search assigned to for users assigned to any Role (all)" do
1816 1903 setup_assigned_to_role
1817 1904 @query.add_filter('assigned_to_role', '*', [''])
1818 1905
1819 1906 assert_query_result [@issue1, @issue2, @issue3], @query
1820 1907 end
1821 1908
1822 1909 test "assigned_to_role filter should return issues with ! empty role" do
1823 1910 setup_assigned_to_role
1824 1911 @empty_role = Role.generate!
1825 1912 @query.add_filter('assigned_to_role', '!', [@empty_role.id.to_s])
1826 1913
1827 1914 assert_query_result [@issue1, @issue2, @issue3, @issue4, @issue5], @query
1828 1915 end
1829 1916
1830 1917 def test_query_column_should_accept_a_symbol_as_caption
1831 1918 set_language_if_valid 'en'
1832 1919 c = QueryColumn.new('foo', :caption => :general_text_Yes)
1833 1920 assert_equal 'Yes', c.caption
1834 1921 end
1835 1922
1836 1923 def test_query_column_should_accept_a_proc_as_caption
1837 1924 c = QueryColumn.new('foo', :caption => lambda {'Foo'})
1838 1925 assert_equal 'Foo', c.caption
1839 1926 end
1840 1927
1841 1928 def test_date_clause_should_respect_user_time_zone_with_local_default
1842 1929 @query = IssueQuery.new(:name => '_')
1843 1930
1844 1931 # user is in Hawaii (-10)
1845 1932 User.current = users(:users_001)
1846 1933 User.current.pref.update_attribute :time_zone, 'Hawaii'
1847 1934
1848 1935 # assume timestamps are stored in server local time
1849 1936 local_zone = Time.zone
1850 1937
1851 1938 from = Date.parse '2016-03-20'
1852 1939 to = Date.parse '2016-03-22'
1853 1940 assert c = @query.send(:date_clause, 'table', 'field', from, to, false)
1854 1941
1855 1942 # the dates should have been interpreted in the user's time zone and
1856 1943 # converted to local time
1857 1944 # what we get exactly in the sql depends on the local time zone, therefore
1858 1945 # it's computed here.
1859 1946 f = User.current.time_zone.local(from.year, from.month, from.day).yesterday.end_of_day.in_time_zone(local_zone)
1860 1947 t = User.current.time_zone.local(to.year, to.month, to.day).end_of_day.in_time_zone(local_zone)
1861 1948 assert_equal "table.field > '#{Query.connection.quoted_date f}' AND table.field <= '#{Query.connection.quoted_date t}'", c
1862 1949 end
1863 1950
1864 1951 def test_date_clause_should_respect_user_time_zone_with_utc_default
1865 1952 @query = IssueQuery.new(:name => '_')
1866 1953
1867 1954 # user is in Hawaii (-10)
1868 1955 User.current = users(:users_001)
1869 1956 User.current.pref.update_attribute :time_zone, 'Hawaii'
1870 1957
1871 1958 # assume timestamps are stored as utc
1872 1959 ActiveRecord::Base.default_timezone = :utc
1873 1960
1874 1961 from = Date.parse '2016-03-20'
1875 1962 to = Date.parse '2016-03-22'
1876 1963 assert c = @query.send(:date_clause, 'table', 'field', from, to, false)
1877 1964 # the dates should have been interpreted in the user's time zone and
1878 1965 # converted to utc. March 20 in Hawaii begins at 10am UTC.
1879 1966 f = Time.new(2016, 3, 20, 9, 59, 59, 0).end_of_hour
1880 1967 t = Time.new(2016, 3, 23, 9, 59, 59, 0).end_of_hour
1881 1968 assert_equal "table.field > '#{Query.connection.quoted_date f}' AND table.field <= '#{Query.connection.quoted_date t}'", c
1882 1969 ensure
1883 1970 ActiveRecord::Base.default_timezone = :local # restore Redmine default
1884 1971 end
1885 1972
1886 1973 def test_filter_on_subprojects
1887 1974 query = IssueQuery.new(:name => '_', :project => Project.find(1))
1888 1975 filter_name = "subproject_id"
1889 1976 assert_include filter_name, query.available_filters.keys
1890 1977
1891 1978 # "is" operator should include issues of parent project + issues of the selected subproject
1892 1979 query.filters = {filter_name => {:operator => '=', :values => ['3']}}
1893 1980 issues = find_issues_with_query(query)
1894 1981 assert_equal [1, 2, 3, 5, 7, 8, 11, 12, 13, 14], issues.map(&:id).sort
1895 1982
1896 1983 # "is not" operator should include issues of parent project + issues of all active subprojects - issues of the selected subprojects
1897 1984 query = IssueQuery.new(:name => '_', :project => Project.find(1))
1898 1985 query.filters = {filter_name => {:operator => '!', :values => ['3']}}
1899 1986 issues = find_issues_with_query(query)
1900 1987 assert_equal [1, 2, 3, 6, 7, 8, 9, 10, 11, 12], issues.map(&:id).sort
1901 1988 end
1902 1989
1903 1990 def test_filter_updated_on_none_should_return_issues_with_updated_on_equal_with_created_on
1904 1991 query = IssueQuery.new(:name => '_', :project => Project.find(1))
1905 1992
1906 1993 query.filters = {'updated_on' => {:operator => '!*', :values => ['']}}
1907 1994 issues = find_issues_with_query(query)
1908 1995 assert_equal [3, 6, 7, 8, 9, 10, 14], issues.map(&:id).sort
1909 1996 end
1910 1997
1911 1998 def test_filter_updated_on_any_should_return_issues_with_updated_on_greater_than_created_on
1912 1999 query = IssueQuery.new(:name => '_', :project => Project.find(1))
1913 2000
1914 2001 query.filters = {'updated_on' => {:operator => '*', :values => ['']}}
1915 2002 issues = find_issues_with_query(query)
1916 2003 assert_equal [1, 2, 5, 11, 12, 13], issues.map(&:id).sort
1917 2004 end
1918 2005 end
General Comments 0
You need to be logged in to leave comments. Login now