##// END OF EJS Templates
Makes issue custom fields available as timelog columns (#1766)....
Jean-Philippe Lang -
r10944:e18d0e268de5
parent child
Show More
@@ -1,769 +1,791
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2013 Jean-Philippe Lang
2 # Copyright (C) 2006-2013 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class QueryColumn
18 class QueryColumn
19 attr_accessor :name, :sortable, :groupable, :default_order
19 attr_accessor :name, :sortable, :groupable, :default_order
20 include Redmine::I18n
20 include Redmine::I18n
21
21
22 def initialize(name, options={})
22 def initialize(name, options={})
23 self.name = name
23 self.name = name
24 self.sortable = options[:sortable]
24 self.sortable = options[:sortable]
25 self.groupable = options[:groupable] || false
25 self.groupable = options[:groupable] || false
26 if groupable == true
26 if groupable == true
27 self.groupable = name.to_s
27 self.groupable = name.to_s
28 end
28 end
29 self.default_order = options[:default_order]
29 self.default_order = options[:default_order]
30 @inline = options.key?(:inline) ? options[:inline] : true
30 @inline = options.key?(:inline) ? options[:inline] : true
31 @caption_key = options[:caption] || "field_#{name}"
31 @caption_key = options[:caption] || "field_#{name}"
32 end
32 end
33
33
34 def caption
34 def caption
35 l(@caption_key)
35 l(@caption_key)
36 end
36 end
37
37
38 # Returns true if the column is sortable, otherwise false
38 # Returns true if the column is sortable, otherwise false
39 def sortable?
39 def sortable?
40 !@sortable.nil?
40 !@sortable.nil?
41 end
41 end
42
42
43 def sortable
43 def sortable
44 @sortable.is_a?(Proc) ? @sortable.call : @sortable
44 @sortable.is_a?(Proc) ? @sortable.call : @sortable
45 end
45 end
46
46
47 def inline?
47 def inline?
48 @inline
48 @inline
49 end
49 end
50
50
51 def value(object)
51 def value(object)
52 object.send name
52 object.send name
53 end
53 end
54
54
55 def css_classes
55 def css_classes
56 name
56 name
57 end
57 end
58 end
58 end
59
59
60 class QueryCustomFieldColumn < QueryColumn
60 class QueryCustomFieldColumn < QueryColumn
61
61
62 def initialize(custom_field)
62 def initialize(custom_field)
63 self.name = "cf_#{custom_field.id}".to_sym
63 self.name = "cf_#{custom_field.id}".to_sym
64 self.sortable = custom_field.order_statement || false
64 self.sortable = custom_field.order_statement || false
65 self.groupable = custom_field.group_statement || false
65 self.groupable = custom_field.group_statement || false
66 @inline = true
66 @inline = true
67 @cf = custom_field
67 @cf = custom_field
68 end
68 end
69
69
70 def caption
70 def caption
71 @cf.name
71 @cf.name
72 end
72 end
73
73
74 def custom_field
74 def custom_field
75 @cf
75 @cf
76 end
76 end
77
77
78 def value(object)
78 def value(object)
79 cv = object.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
79 cv = object.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
80 cv.size > 1 ? cv.sort {|a,b| a.to_s <=> b.to_s} : cv.first
80 cv.size > 1 ? cv.sort {|a,b| a.to_s <=> b.to_s} : cv.first
81 end
81 end
82
82
83 def css_classes
83 def css_classes
84 @css_classes ||= "#{name} #{@cf.field_format}"
84 @css_classes ||= "#{name} #{@cf.field_format}"
85 end
85 end
86 end
86 end
87
87
88 class QueryAssociationCustomFieldColumn < QueryCustomFieldColumn
89
90 def initialize(association, custom_field)
91 super(custom_field)
92 self.name = "#{association}.cf_#{custom_field.id}".to_sym
93 # TODO: support sorting/grouping by association custom field
94 self.sortable = false
95 self.groupable = false
96 @association = association
97 end
98
99 def value(object)
100 if assoc = object.send(@association)
101 super(assoc)
102 end
103 end
104
105 def css_classes
106 @css_classes ||= "#{@association}_cf_#{@cf.id} #{@cf.field_format}"
107 end
108 end
109
88 class Query < ActiveRecord::Base
110 class Query < ActiveRecord::Base
89 class StatementInvalid < ::ActiveRecord::StatementInvalid
111 class StatementInvalid < ::ActiveRecord::StatementInvalid
90 end
112 end
91
113
92 belongs_to :project
114 belongs_to :project
93 belongs_to :user
115 belongs_to :user
94 serialize :filters
116 serialize :filters
95 serialize :column_names
117 serialize :column_names
96 serialize :sort_criteria, Array
118 serialize :sort_criteria, Array
97
119
98 attr_protected :project_id, :user_id
120 attr_protected :project_id, :user_id
99
121
100 validates_presence_of :name
122 validates_presence_of :name
101 validates_length_of :name, :maximum => 255
123 validates_length_of :name, :maximum => 255
102 validate :validate_query_filters
124 validate :validate_query_filters
103
125
104 class_attribute :operators
126 class_attribute :operators
105 self.operators = {
127 self.operators = {
106 "=" => :label_equals,
128 "=" => :label_equals,
107 "!" => :label_not_equals,
129 "!" => :label_not_equals,
108 "o" => :label_open_issues,
130 "o" => :label_open_issues,
109 "c" => :label_closed_issues,
131 "c" => :label_closed_issues,
110 "!*" => :label_none,
132 "!*" => :label_none,
111 "*" => :label_any,
133 "*" => :label_any,
112 ">=" => :label_greater_or_equal,
134 ">=" => :label_greater_or_equal,
113 "<=" => :label_less_or_equal,
135 "<=" => :label_less_or_equal,
114 "><" => :label_between,
136 "><" => :label_between,
115 "<t+" => :label_in_less_than,
137 "<t+" => :label_in_less_than,
116 ">t+" => :label_in_more_than,
138 ">t+" => :label_in_more_than,
117 "><t+"=> :label_in_the_next_days,
139 "><t+"=> :label_in_the_next_days,
118 "t+" => :label_in,
140 "t+" => :label_in,
119 "t" => :label_today,
141 "t" => :label_today,
120 "ld" => :label_yesterday,
142 "ld" => :label_yesterday,
121 "w" => :label_this_week,
143 "w" => :label_this_week,
122 "lw" => :label_last_week,
144 "lw" => :label_last_week,
123 "l2w" => [:label_last_n_weeks, {:count => 2}],
145 "l2w" => [:label_last_n_weeks, {:count => 2}],
124 "m" => :label_this_month,
146 "m" => :label_this_month,
125 "lm" => :label_last_month,
147 "lm" => :label_last_month,
126 "y" => :label_this_year,
148 "y" => :label_this_year,
127 ">t-" => :label_less_than_ago,
149 ">t-" => :label_less_than_ago,
128 "<t-" => :label_more_than_ago,
150 "<t-" => :label_more_than_ago,
129 "><t-"=> :label_in_the_past_days,
151 "><t-"=> :label_in_the_past_days,
130 "t-" => :label_ago,
152 "t-" => :label_ago,
131 "~" => :label_contains,
153 "~" => :label_contains,
132 "!~" => :label_not_contains,
154 "!~" => :label_not_contains,
133 "=p" => :label_any_issues_in_project,
155 "=p" => :label_any_issues_in_project,
134 "=!p" => :label_any_issues_not_in_project,
156 "=!p" => :label_any_issues_not_in_project,
135 "!p" => :label_no_issues_in_project
157 "!p" => :label_no_issues_in_project
136 }
158 }
137
159
138 class_attribute :operators_by_filter_type
160 class_attribute :operators_by_filter_type
139 self.operators_by_filter_type = {
161 self.operators_by_filter_type = {
140 :list => [ "=", "!" ],
162 :list => [ "=", "!" ],
141 :list_status => [ "o", "=", "!", "c", "*" ],
163 :list_status => [ "o", "=", "!", "c", "*" ],
142 :list_optional => [ "=", "!", "!*", "*" ],
164 :list_optional => [ "=", "!", "!*", "*" ],
143 :list_subprojects => [ "*", "!*", "=" ],
165 :list_subprojects => [ "*", "!*", "=" ],
144 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
166 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
145 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "!*", "*" ],
167 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "!*", "*" ],
146 :string => [ "=", "~", "!", "!~", "!*", "*" ],
168 :string => [ "=", "~", "!", "!~", "!*", "*" ],
147 :text => [ "~", "!~", "!*", "*" ],
169 :text => [ "~", "!~", "!*", "*" ],
148 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
170 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
149 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
171 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
150 :relation => ["=", "=p", "=!p", "!p", "!*", "*"]
172 :relation => ["=", "=p", "=!p", "!p", "!*", "*"]
151 }
173 }
152
174
153 class_attribute :available_columns
175 class_attribute :available_columns
154 self.available_columns = []
176 self.available_columns = []
155
177
156 class_attribute :queried_class
178 class_attribute :queried_class
157
179
158 def queried_table_name
180 def queried_table_name
159 @queried_table_name ||= self.class.queried_class.table_name
181 @queried_table_name ||= self.class.queried_class.table_name
160 end
182 end
161
183
162 def initialize(attributes=nil, *args)
184 def initialize(attributes=nil, *args)
163 super attributes
185 super attributes
164 @is_for_all = project.nil?
186 @is_for_all = project.nil?
165 end
187 end
166
188
167 # Builds the query from the given params
189 # Builds the query from the given params
168 def build_from_params(params)
190 def build_from_params(params)
169 if params[:fields] || params[:f]
191 if params[:fields] || params[:f]
170 self.filters = {}
192 self.filters = {}
171 add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v])
193 add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v])
172 else
194 else
173 available_filters.keys.each do |field|
195 available_filters.keys.each do |field|
174 add_short_filter(field, params[field]) if params[field]
196 add_short_filter(field, params[field]) if params[field]
175 end
197 end
176 end
198 end
177 self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
199 self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
178 self.column_names = params[:c] || (params[:query] && params[:query][:column_names])
200 self.column_names = params[:c] || (params[:query] && params[:query][:column_names])
179 self
201 self
180 end
202 end
181
203
182 # Builds a new query from the given params and attributes
204 # Builds a new query from the given params and attributes
183 def self.build_from_params(params, attributes={})
205 def self.build_from_params(params, attributes={})
184 new(attributes).build_from_params(params)
206 new(attributes).build_from_params(params)
185 end
207 end
186
208
187 def validate_query_filters
209 def validate_query_filters
188 filters.each_key do |field|
210 filters.each_key do |field|
189 if values_for(field)
211 if values_for(field)
190 case type_for(field)
212 case type_for(field)
191 when :integer
213 when :integer
192 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+$/) }
214 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+$/) }
193 when :float
215 when :float
194 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+(\.\d*)?$/) }
216 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+(\.\d*)?$/) }
195 when :date, :date_past
217 when :date, :date_past
196 case operator_for(field)
218 case operator_for(field)
197 when "=", ">=", "<=", "><"
219 when "=", ">=", "<=", "><"
198 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && (!v.match(/^\d{4}-\d{2}-\d{2}$/) || (Date.parse(v) rescue nil).nil?) }
220 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && (!v.match(/^\d{4}-\d{2}-\d{2}$/) || (Date.parse(v) rescue nil).nil?) }
199 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
221 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
200 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
222 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
201 end
223 end
202 end
224 end
203 end
225 end
204
226
205 add_filter_error(field, :blank) unless
227 add_filter_error(field, :blank) unless
206 # filter requires one or more values
228 # filter requires one or more values
207 (values_for(field) and !values_for(field).first.blank?) or
229 (values_for(field) and !values_for(field).first.blank?) or
208 # filter doesn't require any value
230 # filter doesn't require any value
209 ["o", "c", "!*", "*", "t", "ld", "w", "lw", "l2w", "m", "lm", "y"].include? operator_for(field)
231 ["o", "c", "!*", "*", "t", "ld", "w", "lw", "l2w", "m", "lm", "y"].include? operator_for(field)
210 end if filters
232 end if filters
211 end
233 end
212
234
213 def add_filter_error(field, message)
235 def add_filter_error(field, message)
214 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
236 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
215 errors.add(:base, m)
237 errors.add(:base, m)
216 end
238 end
217
239
218 def editable_by?(user)
240 def editable_by?(user)
219 return false unless user
241 return false unless user
220 # Admin can edit them all and regular users can edit their private queries
242 # Admin can edit them all and regular users can edit their private queries
221 return true if user.admin? || (!is_public && self.user_id == user.id)
243 return true if user.admin? || (!is_public && self.user_id == user.id)
222 # Members can not edit public queries that are for all project (only admin is allowed to)
244 # Members can not edit public queries that are for all project (only admin is allowed to)
223 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
245 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
224 end
246 end
225
247
226 def trackers
248 def trackers
227 @trackers ||= project.nil? ? Tracker.sorted.all : project.rolled_up_trackers
249 @trackers ||= project.nil? ? Tracker.sorted.all : project.rolled_up_trackers
228 end
250 end
229
251
230 # Returns a hash of localized labels for all filter operators
252 # Returns a hash of localized labels for all filter operators
231 def self.operators_labels
253 def self.operators_labels
232 operators.inject({}) {|h, operator| h[operator.first] = l(*operator.last); h}
254 operators.inject({}) {|h, operator| h[operator.first] = l(*operator.last); h}
233 end
255 end
234
256
235 # Returns a representation of the available filters for JSON serialization
257 # Returns a representation of the available filters for JSON serialization
236 def available_filters_as_json
258 def available_filters_as_json
237 json = {}
259 json = {}
238 available_filters.each do |field, options|
260 available_filters.each do |field, options|
239 json[field] = options.slice(:type, :name, :values).stringify_keys
261 json[field] = options.slice(:type, :name, :values).stringify_keys
240 end
262 end
241 json
263 json
242 end
264 end
243
265
244 def all_projects
266 def all_projects
245 @all_projects ||= Project.visible.all
267 @all_projects ||= Project.visible.all
246 end
268 end
247
269
248 def all_projects_values
270 def all_projects_values
249 return @all_projects_values if @all_projects_values
271 return @all_projects_values if @all_projects_values
250
272
251 values = []
273 values = []
252 Project.project_tree(all_projects) do |p, level|
274 Project.project_tree(all_projects) do |p, level|
253 prefix = (level > 0 ? ('--' * level + ' ') : '')
275 prefix = (level > 0 ? ('--' * level + ' ') : '')
254 values << ["#{prefix}#{p.name}", p.id.to_s]
276 values << ["#{prefix}#{p.name}", p.id.to_s]
255 end
277 end
256 @all_projects_values = values
278 @all_projects_values = values
257 end
279 end
258
280
259 def add_filter(field, operator, values=nil)
281 def add_filter(field, operator, values=nil)
260 # values must be an array
282 # values must be an array
261 return unless values.nil? || values.is_a?(Array)
283 return unless values.nil? || values.is_a?(Array)
262 # check if field is defined as an available filter
284 # check if field is defined as an available filter
263 if available_filters.has_key? field
285 if available_filters.has_key? field
264 filter_options = available_filters[field]
286 filter_options = available_filters[field]
265 filters[field] = {:operator => operator, :values => (values || [''])}
287 filters[field] = {:operator => operator, :values => (values || [''])}
266 end
288 end
267 end
289 end
268
290
269 def add_short_filter(field, expression)
291 def add_short_filter(field, expression)
270 return unless expression && available_filters.has_key?(field)
292 return unless expression && available_filters.has_key?(field)
271 field_type = available_filters[field][:type]
293 field_type = available_filters[field][:type]
272 operators_by_filter_type[field_type].sort.reverse.detect do |operator|
294 operators_by_filter_type[field_type].sort.reverse.detect do |operator|
273 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
295 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
274 add_filter field, operator, $1.present? ? $1.split('|') : ['']
296 add_filter field, operator, $1.present? ? $1.split('|') : ['']
275 end || add_filter(field, '=', expression.split('|'))
297 end || add_filter(field, '=', expression.split('|'))
276 end
298 end
277
299
278 # Add multiple filters using +add_filter+
300 # Add multiple filters using +add_filter+
279 def add_filters(fields, operators, values)
301 def add_filters(fields, operators, values)
280 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
302 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
281 fields.each do |field|
303 fields.each do |field|
282 add_filter(field, operators[field], values && values[field])
304 add_filter(field, operators[field], values && values[field])
283 end
305 end
284 end
306 end
285 end
307 end
286
308
287 def has_filter?(field)
309 def has_filter?(field)
288 filters and filters[field]
310 filters and filters[field]
289 end
311 end
290
312
291 def type_for(field)
313 def type_for(field)
292 available_filters[field][:type] if available_filters.has_key?(field)
314 available_filters[field][:type] if available_filters.has_key?(field)
293 end
315 end
294
316
295 def operator_for(field)
317 def operator_for(field)
296 has_filter?(field) ? filters[field][:operator] : nil
318 has_filter?(field) ? filters[field][:operator] : nil
297 end
319 end
298
320
299 def values_for(field)
321 def values_for(field)
300 has_filter?(field) ? filters[field][:values] : nil
322 has_filter?(field) ? filters[field][:values] : nil
301 end
323 end
302
324
303 def value_for(field, index=0)
325 def value_for(field, index=0)
304 (values_for(field) || [])[index]
326 (values_for(field) || [])[index]
305 end
327 end
306
328
307 def label_for(field)
329 def label_for(field)
308 label = available_filters[field][:name] if available_filters.has_key?(field)
330 label = available_filters[field][:name] if available_filters.has_key?(field)
309 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
331 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
310 end
332 end
311
333
312 def self.add_available_column(column)
334 def self.add_available_column(column)
313 self.available_columns << (column) if column.is_a?(QueryColumn)
335 self.available_columns << (column) if column.is_a?(QueryColumn)
314 end
336 end
315
337
316 # Returns an array of columns that can be used to group the results
338 # Returns an array of columns that can be used to group the results
317 def groupable_columns
339 def groupable_columns
318 available_columns.select {|c| c.groupable}
340 available_columns.select {|c| c.groupable}
319 end
341 end
320
342
321 # Returns a Hash of columns and the key for sorting
343 # Returns a Hash of columns and the key for sorting
322 def sortable_columns
344 def sortable_columns
323 available_columns.inject({}) {|h, column|
345 available_columns.inject({}) {|h, column|
324 h[column.name.to_s] = column.sortable
346 h[column.name.to_s] = column.sortable
325 h
347 h
326 }
348 }
327 end
349 end
328
350
329 def columns
351 def columns
330 # preserve the column_names order
352 # preserve the column_names order
331 (has_default_columns? ? default_columns_names : column_names).collect do |name|
353 (has_default_columns? ? default_columns_names : column_names).collect do |name|
332 available_columns.find { |col| col.name == name }
354 available_columns.find { |col| col.name == name }
333 end.compact
355 end.compact
334 end
356 end
335
357
336 def inline_columns
358 def inline_columns
337 columns.select(&:inline?)
359 columns.select(&:inline?)
338 end
360 end
339
361
340 def block_columns
362 def block_columns
341 columns.reject(&:inline?)
363 columns.reject(&:inline?)
342 end
364 end
343
365
344 def available_inline_columns
366 def available_inline_columns
345 available_columns.select(&:inline?)
367 available_columns.select(&:inline?)
346 end
368 end
347
369
348 def available_block_columns
370 def available_block_columns
349 available_columns.reject(&:inline?)
371 available_columns.reject(&:inline?)
350 end
372 end
351
373
352 def default_columns_names
374 def default_columns_names
353 []
375 []
354 end
376 end
355
377
356 def column_names=(names)
378 def column_names=(names)
357 if names
379 if names
358 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
380 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
359 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
381 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
360 # Set column_names to nil if default columns
382 # Set column_names to nil if default columns
361 if names == default_columns_names
383 if names == default_columns_names
362 names = nil
384 names = nil
363 end
385 end
364 end
386 end
365 write_attribute(:column_names, names)
387 write_attribute(:column_names, names)
366 end
388 end
367
389
368 def has_column?(column)
390 def has_column?(column)
369 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
391 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
370 end
392 end
371
393
372 def has_default_columns?
394 def has_default_columns?
373 column_names.nil? || column_names.empty?
395 column_names.nil? || column_names.empty?
374 end
396 end
375
397
376 def sort_criteria=(arg)
398 def sort_criteria=(arg)
377 c = []
399 c = []
378 if arg.is_a?(Hash)
400 if arg.is_a?(Hash)
379 arg = arg.keys.sort.collect {|k| arg[k]}
401 arg = arg.keys.sort.collect {|k| arg[k]}
380 end
402 end
381 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
403 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
382 write_attribute(:sort_criteria, c)
404 write_attribute(:sort_criteria, c)
383 end
405 end
384
406
385 def sort_criteria
407 def sort_criteria
386 read_attribute(:sort_criteria) || []
408 read_attribute(:sort_criteria) || []
387 end
409 end
388
410
389 def sort_criteria_key(arg)
411 def sort_criteria_key(arg)
390 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
412 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
391 end
413 end
392
414
393 def sort_criteria_order(arg)
415 def sort_criteria_order(arg)
394 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
416 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
395 end
417 end
396
418
397 def sort_criteria_order_for(key)
419 def sort_criteria_order_for(key)
398 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
420 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
399 end
421 end
400
422
401 # Returns the SQL sort order that should be prepended for grouping
423 # Returns the SQL sort order that should be prepended for grouping
402 def group_by_sort_order
424 def group_by_sort_order
403 if grouped? && (column = group_by_column)
425 if grouped? && (column = group_by_column)
404 order = sort_criteria_order_for(column.name) || column.default_order
426 order = sort_criteria_order_for(column.name) || column.default_order
405 column.sortable.is_a?(Array) ?
427 column.sortable.is_a?(Array) ?
406 column.sortable.collect {|s| "#{s} #{order}"}.join(',') :
428 column.sortable.collect {|s| "#{s} #{order}"}.join(',') :
407 "#{column.sortable} #{order}"
429 "#{column.sortable} #{order}"
408 end
430 end
409 end
431 end
410
432
411 # Returns true if the query is a grouped query
433 # Returns true if the query is a grouped query
412 def grouped?
434 def grouped?
413 !group_by_column.nil?
435 !group_by_column.nil?
414 end
436 end
415
437
416 def group_by_column
438 def group_by_column
417 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
439 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
418 end
440 end
419
441
420 def group_by_statement
442 def group_by_statement
421 group_by_column.try(:groupable)
443 group_by_column.try(:groupable)
422 end
444 end
423
445
424 def project_statement
446 def project_statement
425 project_clauses = []
447 project_clauses = []
426 if project && !project.descendants.active.empty?
448 if project && !project.descendants.active.empty?
427 ids = [project.id]
449 ids = [project.id]
428 if has_filter?("subproject_id")
450 if has_filter?("subproject_id")
429 case operator_for("subproject_id")
451 case operator_for("subproject_id")
430 when '='
452 when '='
431 # include the selected subprojects
453 # include the selected subprojects
432 ids += values_for("subproject_id").each(&:to_i)
454 ids += values_for("subproject_id").each(&:to_i)
433 when '!*'
455 when '!*'
434 # main project only
456 # main project only
435 else
457 else
436 # all subprojects
458 # all subprojects
437 ids += project.descendants.collect(&:id)
459 ids += project.descendants.collect(&:id)
438 end
460 end
439 elsif Setting.display_subprojects_issues?
461 elsif Setting.display_subprojects_issues?
440 ids += project.descendants.collect(&:id)
462 ids += project.descendants.collect(&:id)
441 end
463 end
442 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
464 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
443 elsif project
465 elsif project
444 project_clauses << "#{Project.table_name}.id = %d" % project.id
466 project_clauses << "#{Project.table_name}.id = %d" % project.id
445 end
467 end
446 project_clauses.any? ? project_clauses.join(' AND ') : nil
468 project_clauses.any? ? project_clauses.join(' AND ') : nil
447 end
469 end
448
470
449 def statement
471 def statement
450 # filters clauses
472 # filters clauses
451 filters_clauses = []
473 filters_clauses = []
452 filters.each_key do |field|
474 filters.each_key do |field|
453 next if field == "subproject_id"
475 next if field == "subproject_id"
454 v = values_for(field).clone
476 v = values_for(field).clone
455 next unless v and !v.empty?
477 next unless v and !v.empty?
456 operator = operator_for(field)
478 operator = operator_for(field)
457
479
458 # "me" value subsitution
480 # "me" value subsitution
459 if %w(assigned_to_id author_id user_id watcher_id).include?(field)
481 if %w(assigned_to_id author_id user_id watcher_id).include?(field)
460 if v.delete("me")
482 if v.delete("me")
461 if User.current.logged?
483 if User.current.logged?
462 v.push(User.current.id.to_s)
484 v.push(User.current.id.to_s)
463 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
485 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
464 else
486 else
465 v.push("0")
487 v.push("0")
466 end
488 end
467 end
489 end
468 end
490 end
469
491
470 if field == 'project_id'
492 if field == 'project_id'
471 if v.delete('mine')
493 if v.delete('mine')
472 v += User.current.memberships.map(&:project_id).map(&:to_s)
494 v += User.current.memberships.map(&:project_id).map(&:to_s)
473 end
495 end
474 end
496 end
475
497
476 if field =~ /cf_(\d+)$/
498 if field =~ /cf_(\d+)$/
477 # custom field
499 # custom field
478 filters_clauses << sql_for_custom_field(field, operator, v, $1)
500 filters_clauses << sql_for_custom_field(field, operator, v, $1)
479 elsif respond_to?("sql_for_#{field}_field")
501 elsif respond_to?("sql_for_#{field}_field")
480 # specific statement
502 # specific statement
481 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
503 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
482 else
504 else
483 # regular field
505 # regular field
484 filters_clauses << '(' + sql_for_field(field, operator, v, queried_table_name, field) + ')'
506 filters_clauses << '(' + sql_for_field(field, operator, v, queried_table_name, field) + ')'
485 end
507 end
486 end if filters and valid?
508 end if filters and valid?
487
509
488 filters_clauses << project_statement
510 filters_clauses << project_statement
489 filters_clauses.reject!(&:blank?)
511 filters_clauses.reject!(&:blank?)
490
512
491 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
513 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
492 end
514 end
493
515
494 private
516 private
495
517
496 def sql_for_custom_field(field, operator, value, custom_field_id)
518 def sql_for_custom_field(field, operator, value, custom_field_id)
497 db_table = CustomValue.table_name
519 db_table = CustomValue.table_name
498 db_field = 'value'
520 db_field = 'value'
499 filter = @available_filters[field]
521 filter = @available_filters[field]
500 return nil unless filter
522 return nil unless filter
501 if filter[:format] == 'user'
523 if filter[:format] == 'user'
502 if value.delete('me')
524 if value.delete('me')
503 value.push User.current.id.to_s
525 value.push User.current.id.to_s
504 end
526 end
505 end
527 end
506 not_in = nil
528 not_in = nil
507 if operator == '!'
529 if operator == '!'
508 # Makes ! operator work for custom fields with multiple values
530 # Makes ! operator work for custom fields with multiple values
509 operator = '='
531 operator = '='
510 not_in = 'NOT'
532 not_in = 'NOT'
511 end
533 end
512 customized_key = "id"
534 customized_key = "id"
513 customized_class = queried_class
535 customized_class = queried_class
514 if field =~ /^(.+)\.cf_/
536 if field =~ /^(.+)\.cf_/
515 assoc = $1
537 assoc = $1
516 customized_key = "#{assoc}_id"
538 customized_key = "#{assoc}_id"
517 customized_class = queried_class.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
539 customized_class = queried_class.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
518 raise "Unknown #{queried_class.name} association #{assoc}" unless customized_class
540 raise "Unknown #{queried_class.name} association #{assoc}" unless customized_class
519 end
541 end
520 "#{queried_table_name}.#{customized_key} #{not_in} IN (SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name} 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} WHERE " +
542 "#{queried_table_name}.#{customized_key} #{not_in} IN (SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name} 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} WHERE " +
521 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
543 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
522 end
544 end
523
545
524 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
546 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
525 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
547 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
526 sql = ''
548 sql = ''
527 case operator
549 case operator
528 when "="
550 when "="
529 if value.any?
551 if value.any?
530 case type_for(field)
552 case type_for(field)
531 when :date, :date_past
553 when :date, :date_past
532 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
554 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
533 when :integer
555 when :integer
534 if is_custom_filter
556 if is_custom_filter
535 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_i})"
557 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_i})"
536 else
558 else
537 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
559 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
538 end
560 end
539 when :float
561 when :float
540 if is_custom_filter
562 if is_custom_filter
541 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})"
563 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})"
542 else
564 else
543 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
565 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
544 end
566 end
545 else
567 else
546 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
568 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
547 end
569 end
548 else
570 else
549 # IN an empty set
571 # IN an empty set
550 sql = "1=0"
572 sql = "1=0"
551 end
573 end
552 when "!"
574 when "!"
553 if value.any?
575 if value.any?
554 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
576 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
555 else
577 else
556 # NOT IN an empty set
578 # NOT IN an empty set
557 sql = "1=1"
579 sql = "1=1"
558 end
580 end
559 when "!*"
581 when "!*"
560 sql = "#{db_table}.#{db_field} IS NULL"
582 sql = "#{db_table}.#{db_field} IS NULL"
561 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
583 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
562 when "*"
584 when "*"
563 sql = "#{db_table}.#{db_field} IS NOT NULL"
585 sql = "#{db_table}.#{db_field} IS NOT NULL"
564 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
586 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
565 when ">="
587 when ">="
566 if [:date, :date_past].include?(type_for(field))
588 if [:date, :date_past].include?(type_for(field))
567 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
589 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
568 else
590 else
569 if is_custom_filter
591 if is_custom_filter
570 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})"
592 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})"
571 else
593 else
572 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
594 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
573 end
595 end
574 end
596 end
575 when "<="
597 when "<="
576 if [:date, :date_past].include?(type_for(field))
598 if [:date, :date_past].include?(type_for(field))
577 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
599 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
578 else
600 else
579 if is_custom_filter
601 if is_custom_filter
580 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})"
602 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})"
581 else
603 else
582 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
604 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
583 end
605 end
584 end
606 end
585 when "><"
607 when "><"
586 if [:date, :date_past].include?(type_for(field))
608 if [:date, :date_past].include?(type_for(field))
587 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
609 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
588 else
610 else
589 if is_custom_filter
611 if is_custom_filter
590 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})"
612 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})"
591 else
613 else
592 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
614 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
593 end
615 end
594 end
616 end
595 when "o"
617 when "o"
596 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
618 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
597 when "c"
619 when "c"
598 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
620 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
599 when "><t-"
621 when "><t-"
600 # between today - n days and today
622 # between today - n days and today
601 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
623 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
602 when ">t-"
624 when ">t-"
603 # >= today - n days
625 # >= today - n days
604 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil)
626 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil)
605 when "<t-"
627 when "<t-"
606 # <= today - n days
628 # <= today - n days
607 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
629 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
608 when "t-"
630 when "t-"
609 # = n days in past
631 # = n days in past
610 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
632 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
611 when "><t+"
633 when "><t+"
612 # between today and today + n days
634 # between today and today + n days
613 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
635 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
614 when ">t+"
636 when ">t+"
615 # >= today + n days
637 # >= today + n days
616 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
638 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
617 when "<t+"
639 when "<t+"
618 # <= today + n days
640 # <= today + n days
619 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i)
641 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i)
620 when "t+"
642 when "t+"
621 # = today + n days
643 # = today + n days
622 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
644 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
623 when "t"
645 when "t"
624 # = today
646 # = today
625 sql = relative_date_clause(db_table, db_field, 0, 0)
647 sql = relative_date_clause(db_table, db_field, 0, 0)
626 when "ld"
648 when "ld"
627 # = yesterday
649 # = yesterday
628 sql = relative_date_clause(db_table, db_field, -1, -1)
650 sql = relative_date_clause(db_table, db_field, -1, -1)
629 when "w"
651 when "w"
630 # = this week
652 # = this week
631 first_day_of_week = l(:general_first_day_of_week).to_i
653 first_day_of_week = l(:general_first_day_of_week).to_i
632 day_of_week = Date.today.cwday
654 day_of_week = Date.today.cwday
633 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
655 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
634 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
656 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
635 when "lw"
657 when "lw"
636 # = last week
658 # = last week
637 first_day_of_week = l(:general_first_day_of_week).to_i
659 first_day_of_week = l(:general_first_day_of_week).to_i
638 day_of_week = Date.today.cwday
660 day_of_week = Date.today.cwday
639 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
661 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
640 sql = relative_date_clause(db_table, db_field, - days_ago - 7, - days_ago - 1)
662 sql = relative_date_clause(db_table, db_field, - days_ago - 7, - days_ago - 1)
641 when "l2w"
663 when "l2w"
642 # = last 2 weeks
664 # = last 2 weeks
643 first_day_of_week = l(:general_first_day_of_week).to_i
665 first_day_of_week = l(:general_first_day_of_week).to_i
644 day_of_week = Date.today.cwday
666 day_of_week = Date.today.cwday
645 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
667 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
646 sql = relative_date_clause(db_table, db_field, - days_ago - 14, - days_ago - 1)
668 sql = relative_date_clause(db_table, db_field, - days_ago - 14, - days_ago - 1)
647 when "m"
669 when "m"
648 # = this month
670 # = this month
649 date = Date.today
671 date = Date.today
650 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month)
672 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month)
651 when "lm"
673 when "lm"
652 # = last month
674 # = last month
653 date = Date.today.prev_month
675 date = Date.today.prev_month
654 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month)
676 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month)
655 when "y"
677 when "y"
656 # = this year
678 # = this year
657 date = Date.today
679 date = Date.today
658 sql = date_clause(db_table, db_field, date.beginning_of_year, date.end_of_year)
680 sql = date_clause(db_table, db_field, date.beginning_of_year, date.end_of_year)
659 when "~"
681 when "~"
660 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
682 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
661 when "!~"
683 when "!~"
662 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
684 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
663 else
685 else
664 raise "Unknown query operator #{operator}"
686 raise "Unknown query operator #{operator}"
665 end
687 end
666
688
667 return sql
689 return sql
668 end
690 end
669
691
670 def add_custom_fields_filters(custom_fields, assoc=nil)
692 def add_custom_fields_filters(custom_fields, assoc=nil)
671 return unless custom_fields.present?
693 return unless custom_fields.present?
672 @available_filters ||= {}
694 @available_filters ||= {}
673
695
674 custom_fields.select(&:is_filter?).each do |field|
696 custom_fields.select(&:is_filter?).each do |field|
675 case field.field_format
697 case field.field_format
676 when "text"
698 when "text"
677 options = { :type => :text, :order => 20 }
699 options = { :type => :text, :order => 20 }
678 when "list"
700 when "list"
679 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
701 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
680 when "date"
702 when "date"
681 options = { :type => :date, :order => 20 }
703 options = { :type => :date, :order => 20 }
682 when "bool"
704 when "bool"
683 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
705 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
684 when "int"
706 when "int"
685 options = { :type => :integer, :order => 20 }
707 options = { :type => :integer, :order => 20 }
686 when "float"
708 when "float"
687 options = { :type => :float, :order => 20 }
709 options = { :type => :float, :order => 20 }
688 when "user", "version"
710 when "user", "version"
689 next unless project
711 next unless project
690 values = field.possible_values_options(project)
712 values = field.possible_values_options(project)
691 if User.current.logged? && field.field_format == 'user'
713 if User.current.logged? && field.field_format == 'user'
692 values.unshift ["<< #{l(:label_me)} >>", "me"]
714 values.unshift ["<< #{l(:label_me)} >>", "me"]
693 end
715 end
694 options = { :type => :list_optional, :values => values, :order => 20}
716 options = { :type => :list_optional, :values => values, :order => 20}
695 else
717 else
696 options = { :type => :string, :order => 20 }
718 options = { :type => :string, :order => 20 }
697 end
719 end
698 filter_id = "cf_#{field.id}"
720 filter_id = "cf_#{field.id}"
699 filter_name = field.name
721 filter_name = field.name
700 if assoc.present?
722 if assoc.present?
701 filter_id = "#{assoc}.#{filter_id}"
723 filter_id = "#{assoc}.#{filter_id}"
702 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
724 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
703 end
725 end
704 @available_filters[filter_id] = options.merge({
726 @available_filters[filter_id] = options.merge({
705 :name => filter_name,
727 :name => filter_name,
706 :format => field.field_format,
728 :format => field.field_format,
707 :field => field
729 :field => field
708 })
730 })
709 end
731 end
710 end
732 end
711
733
712 def add_associations_custom_fields_filters(*associations)
734 def add_associations_custom_fields_filters(*associations)
713 fields_by_class = CustomField.where(:is_filter => true).group_by(&:class)
735 fields_by_class = CustomField.where(:is_filter => true).group_by(&:class)
714 associations.each do |assoc|
736 associations.each do |assoc|
715 association_klass = queried_class.reflect_on_association(assoc).klass
737 association_klass = queried_class.reflect_on_association(assoc).klass
716 fields_by_class.each do |field_class, fields|
738 fields_by_class.each do |field_class, fields|
717 if field_class.customized_class <= association_klass
739 if field_class.customized_class <= association_klass
718 add_custom_fields_filters(fields, assoc)
740 add_custom_fields_filters(fields, assoc)
719 end
741 end
720 end
742 end
721 end
743 end
722 end
744 end
723
745
724 # Returns a SQL clause for a date or datetime field.
746 # Returns a SQL clause for a date or datetime field.
725 def date_clause(table, field, from, to)
747 def date_clause(table, field, from, to)
726 s = []
748 s = []
727 if from
749 if from
728 from_yesterday = from - 1
750 from_yesterday = from - 1
729 from_yesterday_time = Time.local(from_yesterday.year, from_yesterday.month, from_yesterday.day)
751 from_yesterday_time = Time.local(from_yesterday.year, from_yesterday.month, from_yesterday.day)
730 if self.class.default_timezone == :utc
752 if self.class.default_timezone == :utc
731 from_yesterday_time = from_yesterday_time.utc
753 from_yesterday_time = from_yesterday_time.utc
732 end
754 end
733 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_time.end_of_day)])
755 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_time.end_of_day)])
734 end
756 end
735 if to
757 if to
736 to_time = Time.local(to.year, to.month, to.day)
758 to_time = Time.local(to.year, to.month, to.day)
737 if self.class.default_timezone == :utc
759 if self.class.default_timezone == :utc
738 to_time = to_time.utc
760 to_time = to_time.utc
739 end
761 end
740 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_time.end_of_day)])
762 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_time.end_of_day)])
741 end
763 end
742 s.join(' AND ')
764 s.join(' AND ')
743 end
765 end
744
766
745 # Returns a SQL clause for a date or datetime field using relative dates.
767 # Returns a SQL clause for a date or datetime field using relative dates.
746 def relative_date_clause(table, field, days_from, days_to)
768 def relative_date_clause(table, field, days_from, days_to)
747 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
769 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
748 end
770 end
749
771
750 # Additional joins required for the given sort options
772 # Additional joins required for the given sort options
751 def joins_for_order_statement(order_options)
773 def joins_for_order_statement(order_options)
752 joins = []
774 joins = []
753
775
754 if order_options
776 if order_options
755 if order_options.include?('authors')
777 if order_options.include?('authors')
756 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{queried_table_name}.author_id"
778 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{queried_table_name}.author_id"
757 end
779 end
758 order_options.scan(/cf_\d+/).uniq.each do |name|
780 order_options.scan(/cf_\d+/).uniq.each do |name|
759 column = available_columns.detect {|c| c.name.to_s == name}
781 column = available_columns.detect {|c| c.name.to_s == name}
760 join = column && column.custom_field.join_for_order_statement
782 join = column && column.custom_field.join_for_order_statement
761 if join
783 if join
762 joins << join
784 joins << join
763 end
785 end
764 end
786 end
765 end
787 end
766
788
767 joins.any? ? joins.join(' ') : nil
789 joins.any? ? joins.join(' ') : nil
768 end
790 end
769 end
791 end
@@ -1,122 +1,123
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2013 Jean-Philippe Lang
2 # Copyright (C) 2006-2013 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class TimeEntryQuery < Query
18 class TimeEntryQuery < Query
19
19
20 self.queried_class = TimeEntry
20 self.queried_class = TimeEntry
21
21
22 self.available_columns = [
22 self.available_columns = [
23 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
23 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
24 QueryColumn.new(:spent_on, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :default_order => 'desc', :groupable => true),
24 QueryColumn.new(:spent_on, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :default_order => 'desc', :groupable => true),
25 QueryColumn.new(:user, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
25 QueryColumn.new(:user, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
26 QueryColumn.new(:activity, :sortable => "#{TimeEntryActivity.table_name}.position", :groupable => true),
26 QueryColumn.new(:activity, :sortable => "#{TimeEntryActivity.table_name}.position", :groupable => true),
27 QueryColumn.new(:issue, :sortable => "#{Issue.table_name}.id"),
27 QueryColumn.new(:issue, :sortable => "#{Issue.table_name}.id"),
28 QueryColumn.new(:comments),
28 QueryColumn.new(:comments),
29 QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours"),
29 QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours"),
30 ]
30 ]
31
31
32 def initialize(attributes=nil, *args)
32 def initialize(attributes=nil, *args)
33 super attributes
33 super attributes
34 self.filters ||= {}
34 self.filters ||= {}
35 add_filter('spent_on', '*') unless filters.present?
35 add_filter('spent_on', '*') unless filters.present?
36 end
36 end
37
37
38 def available_filters
38 def available_filters
39 return @available_filters if @available_filters
39 return @available_filters if @available_filters
40 @available_filters = {
40 @available_filters = {
41 "spent_on" => { :type => :date_past, :order => 0 },
41 "spent_on" => { :type => :date_past, :order => 0 },
42 "comments" => { :type => :text, :order => 5 },
42 "comments" => { :type => :text, :order => 5 },
43 "hours" => { :type => :float, :order => 6 }
43 "hours" => { :type => :float, :order => 6 }
44 }
44 }
45
45
46 principals = []
46 principals = []
47 if project
47 if project
48 principals += project.principals.sort
48 principals += project.principals.sort
49 unless project.leaf?
49 unless project.leaf?
50 subprojects = project.descendants.visible.all
50 subprojects = project.descendants.visible.all
51 if subprojects.any?
51 if subprojects.any?
52 @available_filters["subproject_id"] = {
52 @available_filters["subproject_id"] = {
53 :type => :list_subprojects, :order => 1,
53 :type => :list_subprojects, :order => 1,
54 :values => subprojects.collect{|s| [s.name, s.id.to_s] }
54 :values => subprojects.collect{|s| [s.name, s.id.to_s] }
55 }
55 }
56 principals += Principal.member_of(subprojects)
56 principals += Principal.member_of(subprojects)
57 end
57 end
58 end
58 end
59 else
59 else
60 if all_projects.any?
60 if all_projects.any?
61 # members of visible projects
61 # members of visible projects
62 principals += Principal.member_of(all_projects)
62 principals += Principal.member_of(all_projects)
63 # project filter
63 # project filter
64 project_values = []
64 project_values = []
65 if User.current.logged? && User.current.memberships.any?
65 if User.current.logged? && User.current.memberships.any?
66 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
66 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
67 end
67 end
68 project_values += all_projects_values
68 project_values += all_projects_values
69 @available_filters["project_id"] = {
69 @available_filters["project_id"] = {
70 :type => :list, :order => 1, :values => project_values
70 :type => :list, :order => 1, :values => project_values
71 } unless project_values.empty?
71 } unless project_values.empty?
72 end
72 end
73 end
73 end
74 principals.uniq!
74 principals.uniq!
75 principals.sort!
75 principals.sort!
76 users = principals.select {|p| p.is_a?(User)}
76 users = principals.select {|p| p.is_a?(User)}
77
77
78 users_values = []
78 users_values = []
79 users_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
79 users_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
80 users_values += users.collect{|s| [s.name, s.id.to_s] }
80 users_values += users.collect{|s| [s.name, s.id.to_s] }
81 @available_filters["user_id"] = {
81 @available_filters["user_id"] = {
82 :type => :list_optional, :order => 2, :values => users_values
82 :type => :list_optional, :order => 2, :values => users_values
83 } unless users_values.empty?
83 } unless users_values.empty?
84
84
85 activities = (project ? project.activities : TimeEntryActivity.shared.active)
85 activities = (project ? project.activities : TimeEntryActivity.shared.active)
86 @available_filters["activity_id"] = {
86 @available_filters["activity_id"] = {
87 :type => :list, :order => 3, :values => activities.map {|a| [a.name, a.id.to_s]}
87 :type => :list, :order => 3, :values => activities.map {|a| [a.name, a.id.to_s]}
88 } unless activities.empty?
88 } unless activities.empty?
89
89
90 add_custom_fields_filters(TimeEntryCustomField.where(:is_filter => true).all)
90 add_custom_fields_filters(TimeEntryCustomField.where(:is_filter => true).all)
91 add_associations_custom_fields_filters :project, :issue, :user
91 add_associations_custom_fields_filters :project, :issue, :user
92
92
93 @available_filters.each do |field, options|
93 @available_filters.each do |field, options|
94 options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
94 options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
95 end
95 end
96 @available_filters
96 @available_filters
97 end
97 end
98
98
99 def available_columns
99 def available_columns
100 return @available_columns if @available_columns
100 return @available_columns if @available_columns
101 @available_columns = self.class.available_columns.dup
101 @available_columns = self.class.available_columns.dup
102 @available_columns += TimeEntryCustomField.all.map {|cf| QueryCustomFieldColumn.new(cf) }
102 @available_columns += TimeEntryCustomField.all.map {|cf| QueryCustomFieldColumn.new(cf) }
103 @available_columns += IssueCustomField.all.map {|cf| QueryAssociationCustomFieldColumn.new(:issue, cf) }
103 @available_columns
104 @available_columns
104 end
105 end
105
106
106 def default_columns_names
107 def default_columns_names
107 @default_columns_names ||= [:project, :spent_on, :user, :activity, :issue, :comments, :hours]
108 @default_columns_names ||= [:project, :spent_on, :user, :activity, :issue, :comments, :hours]
108 end
109 end
109
110
110 # Accepts :from/:to params as shortcut filters
111 # Accepts :from/:to params as shortcut filters
111 def build_from_params(params)
112 def build_from_params(params)
112 super
113 super
113 if params[:from].present? && params[:to].present?
114 if params[:from].present? && params[:to].present?
114 add_filter('spent_on', '><', [params[:from], params[:to]])
115 add_filter('spent_on', '><', [params[:from], params[:to]])
115 elsif params[:from].present?
116 elsif params[:from].present?
116 add_filter('spent_on', '>=', [params[:from]])
117 add_filter('spent_on', '>=', [params[:from]])
117 elsif params[:to].present?
118 elsif params[:to].present?
118 add_filter('spent_on', '<=', [params[:to]])
119 add_filter('spent_on', '<=', [params[:to]])
119 end
120 end
120 self
121 self
121 end
122 end
122 end
123 end
@@ -1,721 +1,731
1 # -*- coding: utf-8 -*-
1 # -*- coding: utf-8 -*-
2 # Redmine - project management software
2 # Redmine - project management software
3 # Copyright (C) 2006-2013 Jean-Philippe Lang
3 # Copyright (C) 2006-2013 Jean-Philippe Lang
4 #
4 #
5 # This program is free software; you can redistribute it and/or
5 # This program is free software; you can redistribute it and/or
6 # modify it under the terms of the GNU General Public License
6 # modify it under the terms of the GNU General Public License
7 # as published by the Free Software Foundation; either version 2
7 # as published by the Free Software Foundation; either version 2
8 # of the License, or (at your option) any later version.
8 # of the License, or (at your option) any later version.
9 #
9 #
10 # This program is distributed in the hope that it will be useful,
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
13 # GNU General Public License for more details.
14 #
14 #
15 # You should have received a copy of the GNU General Public License
15 # You should have received a copy of the GNU General Public License
16 # along with this program; if not, write to the Free Software
16 # along with this program; if not, write to the Free Software
17 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
18
19 require File.expand_path('../../test_helper', __FILE__)
19 require File.expand_path('../../test_helper', __FILE__)
20
20
21 class TimelogControllerTest < ActionController::TestCase
21 class TimelogControllerTest < ActionController::TestCase
22 fixtures :projects, :enabled_modules, :roles, :members,
22 fixtures :projects, :enabled_modules, :roles, :members,
23 :member_roles, :issues, :time_entries, :users,
23 :member_roles, :issues, :time_entries, :users,
24 :trackers, :enumerations, :issue_statuses,
24 :trackers, :enumerations, :issue_statuses,
25 :custom_fields, :custom_values
25 :custom_fields, :custom_values
26
26
27 include Redmine::I18n
27 include Redmine::I18n
28
28
29 def test_new_with_project_id
29 def test_new_with_project_id
30 @request.session[:user_id] = 3
30 @request.session[:user_id] = 3
31 get :new, :project_id => 1
31 get :new, :project_id => 1
32 assert_response :success
32 assert_response :success
33 assert_template 'new'
33 assert_template 'new'
34 assert_select 'select[name=?]', 'time_entry[project_id]', 0
34 assert_select 'select[name=?]', 'time_entry[project_id]', 0
35 assert_select 'input[name=?][value=1][type=hidden]', 'time_entry[project_id]'
35 assert_select 'input[name=?][value=1][type=hidden]', 'time_entry[project_id]'
36 end
36 end
37
37
38 def test_new_with_issue_id
38 def test_new_with_issue_id
39 @request.session[:user_id] = 3
39 @request.session[:user_id] = 3
40 get :new, :issue_id => 2
40 get :new, :issue_id => 2
41 assert_response :success
41 assert_response :success
42 assert_template 'new'
42 assert_template 'new'
43 assert_select 'select[name=?]', 'time_entry[project_id]', 0
43 assert_select 'select[name=?]', 'time_entry[project_id]', 0
44 assert_select 'input[name=?][value=1][type=hidden]', 'time_entry[project_id]'
44 assert_select 'input[name=?][value=1][type=hidden]', 'time_entry[project_id]'
45 end
45 end
46
46
47 def test_new_without_project
47 def test_new_without_project
48 @request.session[:user_id] = 3
48 @request.session[:user_id] = 3
49 get :new
49 get :new
50 assert_response :success
50 assert_response :success
51 assert_template 'new'
51 assert_template 'new'
52 assert_select 'select[name=?]', 'time_entry[project_id]'
52 assert_select 'select[name=?]', 'time_entry[project_id]'
53 assert_select 'input[name=?]', 'time_entry[project_id]', 0
53 assert_select 'input[name=?]', 'time_entry[project_id]', 0
54 end
54 end
55
55
56 def test_new_without_project_should_prefill_the_form
56 def test_new_without_project_should_prefill_the_form
57 @request.session[:user_id] = 3
57 @request.session[:user_id] = 3
58 get :new, :time_entry => {:project_id => '1'}
58 get :new, :time_entry => {:project_id => '1'}
59 assert_response :success
59 assert_response :success
60 assert_template 'new'
60 assert_template 'new'
61 assert_select 'select[name=?]', 'time_entry[project_id]' do
61 assert_select 'select[name=?]', 'time_entry[project_id]' do
62 assert_select 'option[value=1][selected=selected]'
62 assert_select 'option[value=1][selected=selected]'
63 end
63 end
64 assert_select 'input[name=?]', 'time_entry[project_id]', 0
64 assert_select 'input[name=?]', 'time_entry[project_id]', 0
65 end
65 end
66
66
67 def test_new_without_project_should_deny_without_permission
67 def test_new_without_project_should_deny_without_permission
68 Role.all.each {|role| role.remove_permission! :log_time}
68 Role.all.each {|role| role.remove_permission! :log_time}
69 @request.session[:user_id] = 3
69 @request.session[:user_id] = 3
70
70
71 get :new
71 get :new
72 assert_response 403
72 assert_response 403
73 end
73 end
74
74
75 def test_new_should_select_default_activity
75 def test_new_should_select_default_activity
76 @request.session[:user_id] = 3
76 @request.session[:user_id] = 3
77 get :new, :project_id => 1
77 get :new, :project_id => 1
78 assert_response :success
78 assert_response :success
79 assert_select 'select[name=?]', 'time_entry[activity_id]' do
79 assert_select 'select[name=?]', 'time_entry[activity_id]' do
80 assert_select 'option[selected=selected]', :text => 'Development'
80 assert_select 'option[selected=selected]', :text => 'Development'
81 end
81 end
82 end
82 end
83
83
84 def test_new_should_only_show_active_time_entry_activities
84 def test_new_should_only_show_active_time_entry_activities
85 @request.session[:user_id] = 3
85 @request.session[:user_id] = 3
86 get :new, :project_id => 1
86 get :new, :project_id => 1
87 assert_response :success
87 assert_response :success
88 assert_no_tag 'option', :content => 'Inactive Activity'
88 assert_no_tag 'option', :content => 'Inactive Activity'
89 end
89 end
90
90
91 def test_get_edit_existing_time
91 def test_get_edit_existing_time
92 @request.session[:user_id] = 2
92 @request.session[:user_id] = 2
93 get :edit, :id => 2, :project_id => nil
93 get :edit, :id => 2, :project_id => nil
94 assert_response :success
94 assert_response :success
95 assert_template 'edit'
95 assert_template 'edit'
96 # Default activity selected
96 # Default activity selected
97 assert_tag :tag => 'form', :attributes => { :action => '/projects/ecookbook/time_entries/2' }
97 assert_tag :tag => 'form', :attributes => { :action => '/projects/ecookbook/time_entries/2' }
98 end
98 end
99
99
100 def test_get_edit_with_an_existing_time_entry_with_inactive_activity
100 def test_get_edit_with_an_existing_time_entry_with_inactive_activity
101 te = TimeEntry.find(1)
101 te = TimeEntry.find(1)
102 te.activity = TimeEntryActivity.find_by_name("Inactive Activity")
102 te.activity = TimeEntryActivity.find_by_name("Inactive Activity")
103 te.save!
103 te.save!
104
104
105 @request.session[:user_id] = 1
105 @request.session[:user_id] = 1
106 get :edit, :project_id => 1, :id => 1
106 get :edit, :project_id => 1, :id => 1
107 assert_response :success
107 assert_response :success
108 assert_template 'edit'
108 assert_template 'edit'
109 # Blank option since nothing is pre-selected
109 # Blank option since nothing is pre-selected
110 assert_tag :tag => 'option', :content => '--- Please select ---'
110 assert_tag :tag => 'option', :content => '--- Please select ---'
111 end
111 end
112
112
113 def test_post_create
113 def test_post_create
114 # TODO: should POST to issues’ time log instead of project. change form
114 # TODO: should POST to issues’ time log instead of project. change form
115 # and routing
115 # and routing
116 @request.session[:user_id] = 3
116 @request.session[:user_id] = 3
117 post :create, :project_id => 1,
117 post :create, :project_id => 1,
118 :time_entry => {:comments => 'Some work on TimelogControllerTest',
118 :time_entry => {:comments => 'Some work on TimelogControllerTest',
119 # Not the default activity
119 # Not the default activity
120 :activity_id => '11',
120 :activity_id => '11',
121 :spent_on => '2008-03-14',
121 :spent_on => '2008-03-14',
122 :issue_id => '1',
122 :issue_id => '1',
123 :hours => '7.3'}
123 :hours => '7.3'}
124 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
124 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
125
125
126 i = Issue.find(1)
126 i = Issue.find(1)
127 t = TimeEntry.find_by_comments('Some work on TimelogControllerTest')
127 t = TimeEntry.find_by_comments('Some work on TimelogControllerTest')
128 assert_not_nil t
128 assert_not_nil t
129 assert_equal 11, t.activity_id
129 assert_equal 11, t.activity_id
130 assert_equal 7.3, t.hours
130 assert_equal 7.3, t.hours
131 assert_equal 3, t.user_id
131 assert_equal 3, t.user_id
132 assert_equal i, t.issue
132 assert_equal i, t.issue
133 assert_equal i.project, t.project
133 assert_equal i.project, t.project
134 end
134 end
135
135
136 def test_post_create_with_blank_issue
136 def test_post_create_with_blank_issue
137 # TODO: should POST to issues’ time log instead of project. change form
137 # TODO: should POST to issues’ time log instead of project. change form
138 # and routing
138 # and routing
139 @request.session[:user_id] = 3
139 @request.session[:user_id] = 3
140 post :create, :project_id => 1,
140 post :create, :project_id => 1,
141 :time_entry => {:comments => 'Some work on TimelogControllerTest',
141 :time_entry => {:comments => 'Some work on TimelogControllerTest',
142 # Not the default activity
142 # Not the default activity
143 :activity_id => '11',
143 :activity_id => '11',
144 :issue_id => '',
144 :issue_id => '',
145 :spent_on => '2008-03-14',
145 :spent_on => '2008-03-14',
146 :hours => '7.3'}
146 :hours => '7.3'}
147 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
147 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
148
148
149 t = TimeEntry.find_by_comments('Some work on TimelogControllerTest')
149 t = TimeEntry.find_by_comments('Some work on TimelogControllerTest')
150 assert_not_nil t
150 assert_not_nil t
151 assert_equal 11, t.activity_id
151 assert_equal 11, t.activity_id
152 assert_equal 7.3, t.hours
152 assert_equal 7.3, t.hours
153 assert_equal 3, t.user_id
153 assert_equal 3, t.user_id
154 end
154 end
155
155
156 def test_create_and_continue
156 def test_create_and_continue
157 @request.session[:user_id] = 2
157 @request.session[:user_id] = 2
158 post :create, :project_id => 1,
158 post :create, :project_id => 1,
159 :time_entry => {:activity_id => '11',
159 :time_entry => {:activity_id => '11',
160 :issue_id => '',
160 :issue_id => '',
161 :spent_on => '2008-03-14',
161 :spent_on => '2008-03-14',
162 :hours => '7.3'},
162 :hours => '7.3'},
163 :continue => '1'
163 :continue => '1'
164 assert_redirected_to '/projects/ecookbook/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D='
164 assert_redirected_to '/projects/ecookbook/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D='
165 end
165 end
166
166
167 def test_create_and_continue_with_issue_id
167 def test_create_and_continue_with_issue_id
168 @request.session[:user_id] = 2
168 @request.session[:user_id] = 2
169 post :create, :project_id => 1,
169 post :create, :project_id => 1,
170 :time_entry => {:activity_id => '11',
170 :time_entry => {:activity_id => '11',
171 :issue_id => '1',
171 :issue_id => '1',
172 :spent_on => '2008-03-14',
172 :spent_on => '2008-03-14',
173 :hours => '7.3'},
173 :hours => '7.3'},
174 :continue => '1'
174 :continue => '1'
175 assert_redirected_to '/projects/ecookbook/issues/1/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=1'
175 assert_redirected_to '/projects/ecookbook/issues/1/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=1'
176 end
176 end
177
177
178 def test_create_and_continue_without_project
178 def test_create_and_continue_without_project
179 @request.session[:user_id] = 2
179 @request.session[:user_id] = 2
180 post :create, :time_entry => {:project_id => '1',
180 post :create, :time_entry => {:project_id => '1',
181 :activity_id => '11',
181 :activity_id => '11',
182 :issue_id => '',
182 :issue_id => '',
183 :spent_on => '2008-03-14',
183 :spent_on => '2008-03-14',
184 :hours => '7.3'},
184 :hours => '7.3'},
185 :continue => '1'
185 :continue => '1'
186
186
187 assert_redirected_to '/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=&time_entry%5Bproject_id%5D=1'
187 assert_redirected_to '/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=&time_entry%5Bproject_id%5D=1'
188 end
188 end
189
189
190 def test_create_without_log_time_permission_should_be_denied
190 def test_create_without_log_time_permission_should_be_denied
191 @request.session[:user_id] = 2
191 @request.session[:user_id] = 2
192 Role.find_by_name('Manager').remove_permission! :log_time
192 Role.find_by_name('Manager').remove_permission! :log_time
193 post :create, :project_id => 1,
193 post :create, :project_id => 1,
194 :time_entry => {:activity_id => '11',
194 :time_entry => {:activity_id => '11',
195 :issue_id => '',
195 :issue_id => '',
196 :spent_on => '2008-03-14',
196 :spent_on => '2008-03-14',
197 :hours => '7.3'}
197 :hours => '7.3'}
198
198
199 assert_response 403
199 assert_response 403
200 end
200 end
201
201
202 def test_create_with_failure
202 def test_create_with_failure
203 @request.session[:user_id] = 2
203 @request.session[:user_id] = 2
204 post :create, :project_id => 1,
204 post :create, :project_id => 1,
205 :time_entry => {:activity_id => '',
205 :time_entry => {:activity_id => '',
206 :issue_id => '',
206 :issue_id => '',
207 :spent_on => '2008-03-14',
207 :spent_on => '2008-03-14',
208 :hours => '7.3'}
208 :hours => '7.3'}
209
209
210 assert_response :success
210 assert_response :success
211 assert_template 'new'
211 assert_template 'new'
212 end
212 end
213
213
214 def test_create_without_project
214 def test_create_without_project
215 @request.session[:user_id] = 2
215 @request.session[:user_id] = 2
216 assert_difference 'TimeEntry.count' do
216 assert_difference 'TimeEntry.count' do
217 post :create, :time_entry => {:project_id => '1',
217 post :create, :time_entry => {:project_id => '1',
218 :activity_id => '11',
218 :activity_id => '11',
219 :issue_id => '',
219 :issue_id => '',
220 :spent_on => '2008-03-14',
220 :spent_on => '2008-03-14',
221 :hours => '7.3'}
221 :hours => '7.3'}
222 end
222 end
223
223
224 assert_redirected_to '/projects/ecookbook/time_entries'
224 assert_redirected_to '/projects/ecookbook/time_entries'
225 time_entry = TimeEntry.first(:order => 'id DESC')
225 time_entry = TimeEntry.first(:order => 'id DESC')
226 assert_equal 1, time_entry.project_id
226 assert_equal 1, time_entry.project_id
227 end
227 end
228
228
229 def test_create_without_project_should_fail_with_issue_not_inside_project
229 def test_create_without_project_should_fail_with_issue_not_inside_project
230 @request.session[:user_id] = 2
230 @request.session[:user_id] = 2
231 assert_no_difference 'TimeEntry.count' do
231 assert_no_difference 'TimeEntry.count' do
232 post :create, :time_entry => {:project_id => '1',
232 post :create, :time_entry => {:project_id => '1',
233 :activity_id => '11',
233 :activity_id => '11',
234 :issue_id => '5',
234 :issue_id => '5',
235 :spent_on => '2008-03-14',
235 :spent_on => '2008-03-14',
236 :hours => '7.3'}
236 :hours => '7.3'}
237 end
237 end
238
238
239 assert_response :success
239 assert_response :success
240 assert assigns(:time_entry).errors[:issue_id].present?
240 assert assigns(:time_entry).errors[:issue_id].present?
241 end
241 end
242
242
243 def test_create_without_project_should_deny_without_permission
243 def test_create_without_project_should_deny_without_permission
244 @request.session[:user_id] = 2
244 @request.session[:user_id] = 2
245 Project.find(3).disable_module!(:time_tracking)
245 Project.find(3).disable_module!(:time_tracking)
246
246
247 assert_no_difference 'TimeEntry.count' do
247 assert_no_difference 'TimeEntry.count' do
248 post :create, :time_entry => {:project_id => '3',
248 post :create, :time_entry => {:project_id => '3',
249 :activity_id => '11',
249 :activity_id => '11',
250 :issue_id => '',
250 :issue_id => '',
251 :spent_on => '2008-03-14',
251 :spent_on => '2008-03-14',
252 :hours => '7.3'}
252 :hours => '7.3'}
253 end
253 end
254
254
255 assert_response 403
255 assert_response 403
256 end
256 end
257
257
258 def test_create_without_project_with_failure
258 def test_create_without_project_with_failure
259 @request.session[:user_id] = 2
259 @request.session[:user_id] = 2
260 assert_no_difference 'TimeEntry.count' do
260 assert_no_difference 'TimeEntry.count' do
261 post :create, :time_entry => {:project_id => '1',
261 post :create, :time_entry => {:project_id => '1',
262 :activity_id => '11',
262 :activity_id => '11',
263 :issue_id => '',
263 :issue_id => '',
264 :spent_on => '2008-03-14',
264 :spent_on => '2008-03-14',
265 :hours => ''}
265 :hours => ''}
266 end
266 end
267
267
268 assert_response :success
268 assert_response :success
269 assert_tag 'select', :attributes => {:name => 'time_entry[project_id]'},
269 assert_tag 'select', :attributes => {:name => 'time_entry[project_id]'},
270 :child => {:tag => 'option', :attributes => {:value => '1', :selected => 'selected'}}
270 :child => {:tag => 'option', :attributes => {:value => '1', :selected => 'selected'}}
271 end
271 end
272
272
273 def test_update
273 def test_update
274 entry = TimeEntry.find(1)
274 entry = TimeEntry.find(1)
275 assert_equal 1, entry.issue_id
275 assert_equal 1, entry.issue_id
276 assert_equal 2, entry.user_id
276 assert_equal 2, entry.user_id
277
277
278 @request.session[:user_id] = 1
278 @request.session[:user_id] = 1
279 put :update, :id => 1,
279 put :update, :id => 1,
280 :time_entry => {:issue_id => '2',
280 :time_entry => {:issue_id => '2',
281 :hours => '8'}
281 :hours => '8'}
282 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
282 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
283 entry.reload
283 entry.reload
284
284
285 assert_equal 8, entry.hours
285 assert_equal 8, entry.hours
286 assert_equal 2, entry.issue_id
286 assert_equal 2, entry.issue_id
287 assert_equal 2, entry.user_id
287 assert_equal 2, entry.user_id
288 end
288 end
289
289
290 def test_get_bulk_edit
290 def test_get_bulk_edit
291 @request.session[:user_id] = 2
291 @request.session[:user_id] = 2
292 get :bulk_edit, :ids => [1, 2]
292 get :bulk_edit, :ids => [1, 2]
293 assert_response :success
293 assert_response :success
294 assert_template 'bulk_edit'
294 assert_template 'bulk_edit'
295
295
296 assert_select 'ul#bulk-selection' do
296 assert_select 'ul#bulk-selection' do
297 assert_select 'li', 2
297 assert_select 'li', 2
298 assert_select 'li a', :text => '03/23/2007 - eCookbook: 4.25 hours'
298 assert_select 'li a', :text => '03/23/2007 - eCookbook: 4.25 hours'
299 end
299 end
300
300
301 assert_select 'form#bulk_edit_form[action=?]', '/time_entries/bulk_update' do
301 assert_select 'form#bulk_edit_form[action=?]', '/time_entries/bulk_update' do
302 # System wide custom field
302 # System wide custom field
303 assert_select 'select[name=?]', 'time_entry[custom_field_values][10]'
303 assert_select 'select[name=?]', 'time_entry[custom_field_values][10]'
304
304
305 # Activities
305 # Activities
306 assert_select 'select[name=?]', 'time_entry[activity_id]' do
306 assert_select 'select[name=?]', 'time_entry[activity_id]' do
307 assert_select 'option[value=]', :text => '(No change)'
307 assert_select 'option[value=]', :text => '(No change)'
308 assert_select 'option[value=9]', :text => 'Design'
308 assert_select 'option[value=9]', :text => 'Design'
309 end
309 end
310 end
310 end
311 end
311 end
312
312
313 def test_get_bulk_edit_on_different_projects
313 def test_get_bulk_edit_on_different_projects
314 @request.session[:user_id] = 2
314 @request.session[:user_id] = 2
315 get :bulk_edit, :ids => [1, 2, 6]
315 get :bulk_edit, :ids => [1, 2, 6]
316 assert_response :success
316 assert_response :success
317 assert_template 'bulk_edit'
317 assert_template 'bulk_edit'
318 end
318 end
319
319
320 def test_bulk_update
320 def test_bulk_update
321 @request.session[:user_id] = 2
321 @request.session[:user_id] = 2
322 # update time entry activity
322 # update time entry activity
323 post :bulk_update, :ids => [1, 2], :time_entry => { :activity_id => 9}
323 post :bulk_update, :ids => [1, 2], :time_entry => { :activity_id => 9}
324
324
325 assert_response 302
325 assert_response 302
326 # check that the issues were updated
326 # check that the issues were updated
327 assert_equal [9, 9], TimeEntry.find_all_by_id([1, 2]).collect {|i| i.activity_id}
327 assert_equal [9, 9], TimeEntry.find_all_by_id([1, 2]).collect {|i| i.activity_id}
328 end
328 end
329
329
330 def test_bulk_update_with_failure
330 def test_bulk_update_with_failure
331 @request.session[:user_id] = 2
331 @request.session[:user_id] = 2
332 post :bulk_update, :ids => [1, 2], :time_entry => { :hours => 'A'}
332 post :bulk_update, :ids => [1, 2], :time_entry => { :hours => 'A'}
333
333
334 assert_response 302
334 assert_response 302
335 assert_match /Failed to save 2 time entrie/, flash[:error]
335 assert_match /Failed to save 2 time entrie/, flash[:error]
336 end
336 end
337
337
338 def test_bulk_update_on_different_projects
338 def test_bulk_update_on_different_projects
339 @request.session[:user_id] = 2
339 @request.session[:user_id] = 2
340 # makes user a manager on the other project
340 # makes user a manager on the other project
341 Member.create!(:user_id => 2, :project_id => 3, :role_ids => [1])
341 Member.create!(:user_id => 2, :project_id => 3, :role_ids => [1])
342
342
343 # update time entry activity
343 # update time entry activity
344 post :bulk_update, :ids => [1, 2, 4], :time_entry => { :activity_id => 9 }
344 post :bulk_update, :ids => [1, 2, 4], :time_entry => { :activity_id => 9 }
345
345
346 assert_response 302
346 assert_response 302
347 # check that the issues were updated
347 # check that the issues were updated
348 assert_equal [9, 9, 9], TimeEntry.find_all_by_id([1, 2, 4]).collect {|i| i.activity_id}
348 assert_equal [9, 9, 9], TimeEntry.find_all_by_id([1, 2, 4]).collect {|i| i.activity_id}
349 end
349 end
350
350
351 def test_bulk_update_on_different_projects_without_rights
351 def test_bulk_update_on_different_projects_without_rights
352 @request.session[:user_id] = 3
352 @request.session[:user_id] = 3
353 user = User.find(3)
353 user = User.find(3)
354 action = { :controller => "timelog", :action => "bulk_update" }
354 action = { :controller => "timelog", :action => "bulk_update" }
355 assert user.allowed_to?(action, TimeEntry.find(1).project)
355 assert user.allowed_to?(action, TimeEntry.find(1).project)
356 assert ! user.allowed_to?(action, TimeEntry.find(5).project)
356 assert ! user.allowed_to?(action, TimeEntry.find(5).project)
357 post :bulk_update, :ids => [1, 5], :time_entry => { :activity_id => 9 }
357 post :bulk_update, :ids => [1, 5], :time_entry => { :activity_id => 9 }
358 assert_response 403
358 assert_response 403
359 end
359 end
360
360
361 def test_bulk_update_custom_field
361 def test_bulk_update_custom_field
362 @request.session[:user_id] = 2
362 @request.session[:user_id] = 2
363 post :bulk_update, :ids => [1, 2], :time_entry => { :custom_field_values => {'10' => '0'} }
363 post :bulk_update, :ids => [1, 2], :time_entry => { :custom_field_values => {'10' => '0'} }
364
364
365 assert_response 302
365 assert_response 302
366 assert_equal ["0", "0"], TimeEntry.find_all_by_id([1, 2]).collect {|i| i.custom_value_for(10).value}
366 assert_equal ["0", "0"], TimeEntry.find_all_by_id([1, 2]).collect {|i| i.custom_value_for(10).value}
367 end
367 end
368
368
369 def test_post_bulk_update_should_redirect_back_using_the_back_url_parameter
369 def test_post_bulk_update_should_redirect_back_using_the_back_url_parameter
370 @request.session[:user_id] = 2
370 @request.session[:user_id] = 2
371 post :bulk_update, :ids => [1,2], :back_url => '/time_entries'
371 post :bulk_update, :ids => [1,2], :back_url => '/time_entries'
372
372
373 assert_response :redirect
373 assert_response :redirect
374 assert_redirected_to '/time_entries'
374 assert_redirected_to '/time_entries'
375 end
375 end
376
376
377 def test_post_bulk_update_should_not_redirect_back_using_the_back_url_parameter_off_the_host
377 def test_post_bulk_update_should_not_redirect_back_using_the_back_url_parameter_off_the_host
378 @request.session[:user_id] = 2
378 @request.session[:user_id] = 2
379 post :bulk_update, :ids => [1,2], :back_url => 'http://google.com'
379 post :bulk_update, :ids => [1,2], :back_url => 'http://google.com'
380
380
381 assert_response :redirect
381 assert_response :redirect
382 assert_redirected_to :controller => 'timelog', :action => 'index', :project_id => Project.find(1).identifier
382 assert_redirected_to :controller => 'timelog', :action => 'index', :project_id => Project.find(1).identifier
383 end
383 end
384
384
385 def test_post_bulk_update_without_edit_permission_should_be_denied
385 def test_post_bulk_update_without_edit_permission_should_be_denied
386 @request.session[:user_id] = 2
386 @request.session[:user_id] = 2
387 Role.find_by_name('Manager').remove_permission! :edit_time_entries
387 Role.find_by_name('Manager').remove_permission! :edit_time_entries
388 post :bulk_update, :ids => [1,2]
388 post :bulk_update, :ids => [1,2]
389
389
390 assert_response 403
390 assert_response 403
391 end
391 end
392
392
393 def test_destroy
393 def test_destroy
394 @request.session[:user_id] = 2
394 @request.session[:user_id] = 2
395 delete :destroy, :id => 1
395 delete :destroy, :id => 1
396 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
396 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
397 assert_equal I18n.t(:notice_successful_delete), flash[:notice]
397 assert_equal I18n.t(:notice_successful_delete), flash[:notice]
398 assert_nil TimeEntry.find_by_id(1)
398 assert_nil TimeEntry.find_by_id(1)
399 end
399 end
400
400
401 def test_destroy_should_fail
401 def test_destroy_should_fail
402 # simulate that this fails (e.g. due to a plugin), see #5700
402 # simulate that this fails (e.g. due to a plugin), see #5700
403 TimeEntry.any_instance.expects(:destroy).returns(false)
403 TimeEntry.any_instance.expects(:destroy).returns(false)
404
404
405 @request.session[:user_id] = 2
405 @request.session[:user_id] = 2
406 delete :destroy, :id => 1
406 delete :destroy, :id => 1
407 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
407 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
408 assert_equal I18n.t(:notice_unable_delete_time_entry), flash[:error]
408 assert_equal I18n.t(:notice_unable_delete_time_entry), flash[:error]
409 assert_not_nil TimeEntry.find_by_id(1)
409 assert_not_nil TimeEntry.find_by_id(1)
410 end
410 end
411
411
412 def test_index_all_projects
412 def test_index_all_projects
413 get :index
413 get :index
414 assert_response :success
414 assert_response :success
415 assert_template 'index'
415 assert_template 'index'
416 assert_not_nil assigns(:total_hours)
416 assert_not_nil assigns(:total_hours)
417 assert_equal "162.90", "%.2f" % assigns(:total_hours)
417 assert_equal "162.90", "%.2f" % assigns(:total_hours)
418 assert_tag :form,
418 assert_tag :form,
419 :attributes => {:action => "/time_entries", :id => 'query_form'}
419 :attributes => {:action => "/time_entries", :id => 'query_form'}
420 end
420 end
421
421
422 def test_index_all_projects_should_show_log_time_link
422 def test_index_all_projects_should_show_log_time_link
423 @request.session[:user_id] = 2
423 @request.session[:user_id] = 2
424 get :index
424 get :index
425 assert_response :success
425 assert_response :success
426 assert_template 'index'
426 assert_template 'index'
427 assert_tag 'a', :attributes => {:href => '/time_entries/new'}, :content => /Log time/
427 assert_tag 'a', :attributes => {:href => '/time_entries/new'}, :content => /Log time/
428 end
428 end
429
429
430 def test_index_at_project_level
430 def test_index_at_project_level
431 get :index, :project_id => 'ecookbook'
431 get :index, :project_id => 'ecookbook'
432 assert_response :success
432 assert_response :success
433 assert_template 'index'
433 assert_template 'index'
434 assert_not_nil assigns(:entries)
434 assert_not_nil assigns(:entries)
435 assert_equal 4, assigns(:entries).size
435 assert_equal 4, assigns(:entries).size
436 # project and subproject
436 # project and subproject
437 assert_equal [1, 3], assigns(:entries).collect(&:project_id).uniq.sort
437 assert_equal [1, 3], assigns(:entries).collect(&:project_id).uniq.sort
438 assert_not_nil assigns(:total_hours)
438 assert_not_nil assigns(:total_hours)
439 assert_equal "162.90", "%.2f" % assigns(:total_hours)
439 assert_equal "162.90", "%.2f" % assigns(:total_hours)
440 assert_tag :form,
440 assert_tag :form,
441 :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'}
441 :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'}
442 end
442 end
443
443
444 def test_index_at_project_level_with_date_range
444 def test_index_at_project_level_with_date_range
445 get :index, :project_id => 'ecookbook',
445 get :index, :project_id => 'ecookbook',
446 :f => ['spent_on'],
446 :f => ['spent_on'],
447 :op => {'spent_on' => '><'},
447 :op => {'spent_on' => '><'},
448 :v => {'spent_on' => ['2007-03-20', '2007-04-30']}
448 :v => {'spent_on' => ['2007-03-20', '2007-04-30']}
449 assert_response :success
449 assert_response :success
450 assert_template 'index'
450 assert_template 'index'
451 assert_not_nil assigns(:entries)
451 assert_not_nil assigns(:entries)
452 assert_equal 3, assigns(:entries).size
452 assert_equal 3, assigns(:entries).size
453 assert_not_nil assigns(:total_hours)
453 assert_not_nil assigns(:total_hours)
454 assert_equal "12.90", "%.2f" % assigns(:total_hours)
454 assert_equal "12.90", "%.2f" % assigns(:total_hours)
455 assert_tag :form,
455 assert_tag :form,
456 :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'}
456 :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'}
457 end
457 end
458
458
459 def test_index_at_project_level_with_date_range_using_from_and_to_params
459 def test_index_at_project_level_with_date_range_using_from_and_to_params
460 get :index, :project_id => 'ecookbook', :from => '2007-03-20', :to => '2007-04-30'
460 get :index, :project_id => 'ecookbook', :from => '2007-03-20', :to => '2007-04-30'
461 assert_response :success
461 assert_response :success
462 assert_template 'index'
462 assert_template 'index'
463 assert_not_nil assigns(:entries)
463 assert_not_nil assigns(:entries)
464 assert_equal 3, assigns(:entries).size
464 assert_equal 3, assigns(:entries).size
465 assert_not_nil assigns(:total_hours)
465 assert_not_nil assigns(:total_hours)
466 assert_equal "12.90", "%.2f" % assigns(:total_hours)
466 assert_equal "12.90", "%.2f" % assigns(:total_hours)
467 assert_tag :form,
467 assert_tag :form,
468 :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'}
468 :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'}
469 end
469 end
470
470
471 def test_index_at_project_level_with_period
471 def test_index_at_project_level_with_period
472 get :index, :project_id => 'ecookbook',
472 get :index, :project_id => 'ecookbook',
473 :f => ['spent_on'],
473 :f => ['spent_on'],
474 :op => {'spent_on' => '>t-'},
474 :op => {'spent_on' => '>t-'},
475 :v => {'spent_on' => ['7']}
475 :v => {'spent_on' => ['7']}
476 assert_response :success
476 assert_response :success
477 assert_template 'index'
477 assert_template 'index'
478 assert_not_nil assigns(:entries)
478 assert_not_nil assigns(:entries)
479 assert_not_nil assigns(:total_hours)
479 assert_not_nil assigns(:total_hours)
480 assert_tag :form,
480 assert_tag :form,
481 :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'}
481 :attributes => {:action => "/projects/ecookbook/time_entries", :id => 'query_form'}
482 end
482 end
483
483
484 def test_index_at_issue_level
484 def test_index_at_issue_level
485 get :index, :issue_id => 1
485 get :index, :issue_id => 1
486 assert_response :success
486 assert_response :success
487 assert_template 'index'
487 assert_template 'index'
488 assert_not_nil assigns(:entries)
488 assert_not_nil assigns(:entries)
489 assert_equal 2, assigns(:entries).size
489 assert_equal 2, assigns(:entries).size
490 assert_not_nil assigns(:total_hours)
490 assert_not_nil assigns(:total_hours)
491 assert_equal 154.25, assigns(:total_hours)
491 assert_equal 154.25, assigns(:total_hours)
492 # display all time
492 # display all time
493 assert_nil assigns(:from)
493 assert_nil assigns(:from)
494 assert_nil assigns(:to)
494 assert_nil assigns(:to)
495 # TODO: remove /projects/:project_id/issues/:issue_id/time_entries routes
495 # TODO: remove /projects/:project_id/issues/:issue_id/time_entries routes
496 # to use /issues/:issue_id/time_entries
496 # to use /issues/:issue_id/time_entries
497 assert_tag :form,
497 assert_tag :form,
498 :attributes => {:action => "/projects/ecookbook/issues/1/time_entries", :id => 'query_form'}
498 :attributes => {:action => "/projects/ecookbook/issues/1/time_entries", :id => 'query_form'}
499 end
499 end
500
500
501 def test_index_should_sort_by_spent_on_and_created_on
501 def test_index_should_sort_by_spent_on_and_created_on
502 t1 = TimeEntry.create!(:user => User.find(1), :project => Project.find(1), :hours => 1, :spent_on => '2012-06-16', :created_on => '2012-06-16 20:00:00', :activity_id => 10)
502 t1 = TimeEntry.create!(:user => User.find(1), :project => Project.find(1), :hours => 1, :spent_on => '2012-06-16', :created_on => '2012-06-16 20:00:00', :activity_id => 10)
503 t2 = TimeEntry.create!(:user => User.find(1), :project => Project.find(1), :hours => 1, :spent_on => '2012-06-16', :created_on => '2012-06-16 20:05:00', :activity_id => 10)
503 t2 = TimeEntry.create!(:user => User.find(1), :project => Project.find(1), :hours => 1, :spent_on => '2012-06-16', :created_on => '2012-06-16 20:05:00', :activity_id => 10)
504 t3 = TimeEntry.create!(:user => User.find(1), :project => Project.find(1), :hours => 1, :spent_on => '2012-06-15', :created_on => '2012-06-16 20:10:00', :activity_id => 10)
504 t3 = TimeEntry.create!(:user => User.find(1), :project => Project.find(1), :hours => 1, :spent_on => '2012-06-15', :created_on => '2012-06-16 20:10:00', :activity_id => 10)
505
505
506 get :index, :project_id => 1,
506 get :index, :project_id => 1,
507 :f => ['spent_on'],
507 :f => ['spent_on'],
508 :op => {'spent_on' => '><'},
508 :op => {'spent_on' => '><'},
509 :v => {'spent_on' => ['2012-06-15', '2012-06-16']}
509 :v => {'spent_on' => ['2012-06-15', '2012-06-16']}
510 assert_response :success
510 assert_response :success
511 assert_equal [t2, t1, t3], assigns(:entries)
511 assert_equal [t2, t1, t3], assigns(:entries)
512
512
513 get :index, :project_id => 1,
513 get :index, :project_id => 1,
514 :f => ['spent_on'],
514 :f => ['spent_on'],
515 :op => {'spent_on' => '><'},
515 :op => {'spent_on' => '><'},
516 :v => {'spent_on' => ['2012-06-15', '2012-06-16']},
516 :v => {'spent_on' => ['2012-06-15', '2012-06-16']},
517 :sort => 'spent_on'
517 :sort => 'spent_on'
518 assert_response :success
518 assert_response :success
519 assert_equal [t3, t1, t2], assigns(:entries)
519 assert_equal [t3, t1, t2], assigns(:entries)
520 end
520 end
521
521
522 def test_index_with_filter_on_issue_custom_field
522 def test_index_with_filter_on_issue_custom_field
523 issue = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {2 => 'filter_on_issue_custom_field'})
523 issue = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {2 => 'filter_on_issue_custom_field'})
524 entry = TimeEntry.generate!(:issue => issue, :hours => 2.5)
524 entry = TimeEntry.generate!(:issue => issue, :hours => 2.5)
525
525
526 get :index, :f => ['issue.cf_2'], :op => {'issue.cf_2' => '='}, :v => {'issue.cf_2' => ['filter_on_issue_custom_field']}
526 get :index, :f => ['issue.cf_2'], :op => {'issue.cf_2' => '='}, :v => {'issue.cf_2' => ['filter_on_issue_custom_field']}
527 assert_response :success
527 assert_response :success
528 assert_equal [entry], assigns(:entries)
528 assert_equal [entry], assigns(:entries)
529 end
529 end
530
530
531 def test_index_with_issue_custom_field_column
532 issue = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {2 => 'filter_on_issue_custom_field'})
533 entry = TimeEntry.generate!(:issue => issue, :hours => 2.5)
534
535 get :index, :c => %w(project spent_on issue comments hours issue.cf_2)
536 assert_response :success
537 assert_include :'issue.cf_2', assigns(:query).column_names
538 assert_select 'td.issue_cf_2', :text => 'filter_on_issue_custom_field'
539 end
540
531 def test_index_atom_feed
541 def test_index_atom_feed
532 get :index, :project_id => 1, :format => 'atom'
542 get :index, :project_id => 1, :format => 'atom'
533 assert_response :success
543 assert_response :success
534 assert_equal 'application/atom+xml', @response.content_type
544 assert_equal 'application/atom+xml', @response.content_type
535 assert_not_nil assigns(:items)
545 assert_not_nil assigns(:items)
536 assert assigns(:items).first.is_a?(TimeEntry)
546 assert assigns(:items).first.is_a?(TimeEntry)
537 end
547 end
538
548
539 def test_index_all_projects_csv_export
549 def test_index_all_projects_csv_export
540 Setting.date_format = '%m/%d/%Y'
550 Setting.date_format = '%m/%d/%Y'
541 get :index, :format => 'csv'
551 get :index, :format => 'csv'
542 assert_response :success
552 assert_response :success
543 assert_equal 'text/csv; header=present', @response.content_type
553 assert_equal 'text/csv; header=present', @response.content_type
544 assert @response.body.include?("Date,User,Activity,Project,Issue,Tracker,Subject,Hours,Comment,Overtime\n")
554 assert @response.body.include?("Date,User,Activity,Project,Issue,Tracker,Subject,Hours,Comment,Overtime\n")
545 assert @response.body.include?("\n04/21/2007,redMine Admin,Design,eCookbook,3,Bug,Error 281 when updating a recipe,1.0,\"\",\"\"\n")
555 assert @response.body.include?("\n04/21/2007,redMine Admin,Design,eCookbook,3,Bug,Error 281 when updating a recipe,1.0,\"\",\"\"\n")
546 end
556 end
547
557
548 def test_index_csv_export
558 def test_index_csv_export
549 Setting.date_format = '%m/%d/%Y'
559 Setting.date_format = '%m/%d/%Y'
550 get :index, :project_id => 1, :format => 'csv'
560 get :index, :project_id => 1, :format => 'csv'
551 assert_response :success
561 assert_response :success
552 assert_equal 'text/csv; header=present', @response.content_type
562 assert_equal 'text/csv; header=present', @response.content_type
553 assert @response.body.include?("Date,User,Activity,Project,Issue,Tracker,Subject,Hours,Comment,Overtime\n")
563 assert @response.body.include?("Date,User,Activity,Project,Issue,Tracker,Subject,Hours,Comment,Overtime\n")
554 assert @response.body.include?("\n04/21/2007,redMine Admin,Design,eCookbook,3,Bug,Error 281 when updating a recipe,1.0,\"\",\"\"\n")
564 assert @response.body.include?("\n04/21/2007,redMine Admin,Design,eCookbook,3,Bug,Error 281 when updating a recipe,1.0,\"\",\"\"\n")
555 end
565 end
556
566
557 def test_index_csv_export_with_multi_custom_field
567 def test_index_csv_export_with_multi_custom_field
558 field = TimeEntryCustomField.create!(:name => 'Test', :field_format => 'list',
568 field = TimeEntryCustomField.create!(:name => 'Test', :field_format => 'list',
559 :multiple => true, :possible_values => ['value1', 'value2'])
569 :multiple => true, :possible_values => ['value1', 'value2'])
560 entry = TimeEntry.find(1)
570 entry = TimeEntry.find(1)
561 entry.custom_field_values = {field.id => ['value1', 'value2']}
571 entry.custom_field_values = {field.id => ['value1', 'value2']}
562 entry.save!
572 entry.save!
563
573
564 get :index, :project_id => 1, :format => 'csv'
574 get :index, :project_id => 1, :format => 'csv'
565 assert_response :success
575 assert_response :success
566 assert_include '"value1, value2"', @response.body
576 assert_include '"value1, value2"', @response.body
567 end
577 end
568
578
569 def test_csv_big_5
579 def test_csv_big_5
570 user = User.find_by_id(3)
580 user = User.find_by_id(3)
571 user.language = "zh-TW"
581 user.language = "zh-TW"
572 assert user.save
582 assert user.save
573 str_utf8 = "\xe4\xb8\x80\xe6\x9c\x88"
583 str_utf8 = "\xe4\xb8\x80\xe6\x9c\x88"
574 str_big5 = "\xa4@\xa4\xeb"
584 str_big5 = "\xa4@\xa4\xeb"
575 if str_utf8.respond_to?(:force_encoding)
585 if str_utf8.respond_to?(:force_encoding)
576 str_utf8.force_encoding('UTF-8')
586 str_utf8.force_encoding('UTF-8')
577 str_big5.force_encoding('Big5')
587 str_big5.force_encoding('Big5')
578 end
588 end
579 @request.session[:user_id] = 3
589 @request.session[:user_id] = 3
580 post :create, :project_id => 1,
590 post :create, :project_id => 1,
581 :time_entry => {:comments => str_utf8,
591 :time_entry => {:comments => str_utf8,
582 # Not the default activity
592 # Not the default activity
583 :activity_id => '11',
593 :activity_id => '11',
584 :issue_id => '',
594 :issue_id => '',
585 :spent_on => '2011-11-10',
595 :spent_on => '2011-11-10',
586 :hours => '7.3'}
596 :hours => '7.3'}
587 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
597 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
588
598
589 t = TimeEntry.find_by_comments(str_utf8)
599 t = TimeEntry.find_by_comments(str_utf8)
590 assert_not_nil t
600 assert_not_nil t
591 assert_equal 11, t.activity_id
601 assert_equal 11, t.activity_id
592 assert_equal 7.3, t.hours
602 assert_equal 7.3, t.hours
593 assert_equal 3, t.user_id
603 assert_equal 3, t.user_id
594
604
595 get :index, :project_id => 1, :format => 'csv',
605 get :index, :project_id => 1, :format => 'csv',
596 :from => '2011-11-10', :to => '2011-11-10'
606 :from => '2011-11-10', :to => '2011-11-10'
597 assert_response :success
607 assert_response :success
598 assert_equal 'text/csv; header=present', @response.content_type
608 assert_equal 'text/csv; header=present', @response.content_type
599 ar = @response.body.chomp.split("\n")
609 ar = @response.body.chomp.split("\n")
600 s1 = "\xa4\xe9\xb4\xc1"
610 s1 = "\xa4\xe9\xb4\xc1"
601 if str_utf8.respond_to?(:force_encoding)
611 if str_utf8.respond_to?(:force_encoding)
602 s1.force_encoding('Big5')
612 s1.force_encoding('Big5')
603 end
613 end
604 assert ar[0].include?(s1)
614 assert ar[0].include?(s1)
605 assert ar[1].include?(str_big5)
615 assert ar[1].include?(str_big5)
606 end
616 end
607
617
608 def test_csv_cannot_convert_should_be_replaced_big_5
618 def test_csv_cannot_convert_should_be_replaced_big_5
609 user = User.find_by_id(3)
619 user = User.find_by_id(3)
610 user.language = "zh-TW"
620 user.language = "zh-TW"
611 assert user.save
621 assert user.save
612 str_utf8 = "\xe4\xbb\xa5\xe5\x86\x85"
622 str_utf8 = "\xe4\xbb\xa5\xe5\x86\x85"
613 if str_utf8.respond_to?(:force_encoding)
623 if str_utf8.respond_to?(:force_encoding)
614 str_utf8.force_encoding('UTF-8')
624 str_utf8.force_encoding('UTF-8')
615 end
625 end
616 @request.session[:user_id] = 3
626 @request.session[:user_id] = 3
617 post :create, :project_id => 1,
627 post :create, :project_id => 1,
618 :time_entry => {:comments => str_utf8,
628 :time_entry => {:comments => str_utf8,
619 # Not the default activity
629 # Not the default activity
620 :activity_id => '11',
630 :activity_id => '11',
621 :issue_id => '',
631 :issue_id => '',
622 :spent_on => '2011-11-10',
632 :spent_on => '2011-11-10',
623 :hours => '7.3'}
633 :hours => '7.3'}
624 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
634 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
625
635
626 t = TimeEntry.find_by_comments(str_utf8)
636 t = TimeEntry.find_by_comments(str_utf8)
627 assert_not_nil t
637 assert_not_nil t
628 assert_equal 11, t.activity_id
638 assert_equal 11, t.activity_id
629 assert_equal 7.3, t.hours
639 assert_equal 7.3, t.hours
630 assert_equal 3, t.user_id
640 assert_equal 3, t.user_id
631
641
632 get :index, :project_id => 1, :format => 'csv',
642 get :index, :project_id => 1, :format => 'csv',
633 :from => '2011-11-10', :to => '2011-11-10'
643 :from => '2011-11-10', :to => '2011-11-10'
634 assert_response :success
644 assert_response :success
635 assert_equal 'text/csv; header=present', @response.content_type
645 assert_equal 'text/csv; header=present', @response.content_type
636 ar = @response.body.chomp.split("\n")
646 ar = @response.body.chomp.split("\n")
637 s1 = "\xa4\xe9\xb4\xc1"
647 s1 = "\xa4\xe9\xb4\xc1"
638 if str_utf8.respond_to?(:force_encoding)
648 if str_utf8.respond_to?(:force_encoding)
639 s1.force_encoding('Big5')
649 s1.force_encoding('Big5')
640 end
650 end
641 assert ar[0].include?(s1)
651 assert ar[0].include?(s1)
642 s2 = ar[1].split(",")[8]
652 s2 = ar[1].split(",")[8]
643 if s2.respond_to?(:force_encoding)
653 if s2.respond_to?(:force_encoding)
644 s3 = "\xa5H?"
654 s3 = "\xa5H?"
645 s3.force_encoding('Big5')
655 s3.force_encoding('Big5')
646 assert_equal s3, s2
656 assert_equal s3, s2
647 elsif RUBY_PLATFORM == 'java'
657 elsif RUBY_PLATFORM == 'java'
648 assert_equal "??", s2
658 assert_equal "??", s2
649 else
659 else
650 assert_equal "\xa5H???", s2
660 assert_equal "\xa5H???", s2
651 end
661 end
652 end
662 end
653
663
654 def test_csv_tw
664 def test_csv_tw
655 with_settings :default_language => "zh-TW" do
665 with_settings :default_language => "zh-TW" do
656 str1 = "test_csv_tw"
666 str1 = "test_csv_tw"
657 user = User.find_by_id(3)
667 user = User.find_by_id(3)
658 te1 = TimeEntry.create(:spent_on => '2011-11-10',
668 te1 = TimeEntry.create(:spent_on => '2011-11-10',
659 :hours => 999.9,
669 :hours => 999.9,
660 :project => Project.find(1),
670 :project => Project.find(1),
661 :user => user,
671 :user => user,
662 :activity => TimeEntryActivity.find_by_name('Design'),
672 :activity => TimeEntryActivity.find_by_name('Design'),
663 :comments => str1)
673 :comments => str1)
664 te2 = TimeEntry.find_by_comments(str1)
674 te2 = TimeEntry.find_by_comments(str1)
665 assert_not_nil te2
675 assert_not_nil te2
666 assert_equal 999.9, te2.hours
676 assert_equal 999.9, te2.hours
667 assert_equal 3, te2.user_id
677 assert_equal 3, te2.user_id
668
678
669 get :index, :project_id => 1, :format => 'csv',
679 get :index, :project_id => 1, :format => 'csv',
670 :from => '2011-11-10', :to => '2011-11-10'
680 :from => '2011-11-10', :to => '2011-11-10'
671 assert_response :success
681 assert_response :success
672 assert_equal 'text/csv; header=present', @response.content_type
682 assert_equal 'text/csv; header=present', @response.content_type
673
683
674 ar = @response.body.chomp.split("\n")
684 ar = @response.body.chomp.split("\n")
675 s2 = ar[1].split(",")[7]
685 s2 = ar[1].split(",")[7]
676 assert_equal '999.9', s2
686 assert_equal '999.9', s2
677
687
678 str_tw = "Traditional Chinese (\xe7\xb9\x81\xe9\xab\x94\xe4\xb8\xad\xe6\x96\x87)"
688 str_tw = "Traditional Chinese (\xe7\xb9\x81\xe9\xab\x94\xe4\xb8\xad\xe6\x96\x87)"
679 if str_tw.respond_to?(:force_encoding)
689 if str_tw.respond_to?(:force_encoding)
680 str_tw.force_encoding('UTF-8')
690 str_tw.force_encoding('UTF-8')
681 end
691 end
682 assert_equal str_tw, l(:general_lang_name)
692 assert_equal str_tw, l(:general_lang_name)
683 assert_equal ',', l(:general_csv_separator)
693 assert_equal ',', l(:general_csv_separator)
684 assert_equal '.', l(:general_csv_decimal_separator)
694 assert_equal '.', l(:general_csv_decimal_separator)
685 end
695 end
686 end
696 end
687
697
688 def test_csv_fr
698 def test_csv_fr
689 with_settings :default_language => "fr" do
699 with_settings :default_language => "fr" do
690 str1 = "test_csv_fr"
700 str1 = "test_csv_fr"
691 user = User.find_by_id(3)
701 user = User.find_by_id(3)
692 te1 = TimeEntry.create(:spent_on => '2011-11-10',
702 te1 = TimeEntry.create(:spent_on => '2011-11-10',
693 :hours => 999.9,
703 :hours => 999.9,
694 :project => Project.find(1),
704 :project => Project.find(1),
695 :user => user,
705 :user => user,
696 :activity => TimeEntryActivity.find_by_name('Design'),
706 :activity => TimeEntryActivity.find_by_name('Design'),
697 :comments => str1)
707 :comments => str1)
698 te2 = TimeEntry.find_by_comments(str1)
708 te2 = TimeEntry.find_by_comments(str1)
699 assert_not_nil te2
709 assert_not_nil te2
700 assert_equal 999.9, te2.hours
710 assert_equal 999.9, te2.hours
701 assert_equal 3, te2.user_id
711 assert_equal 3, te2.user_id
702
712
703 get :index, :project_id => 1, :format => 'csv',
713 get :index, :project_id => 1, :format => 'csv',
704 :from => '2011-11-10', :to => '2011-11-10'
714 :from => '2011-11-10', :to => '2011-11-10'
705 assert_response :success
715 assert_response :success
706 assert_equal 'text/csv; header=present', @response.content_type
716 assert_equal 'text/csv; header=present', @response.content_type
707
717
708 ar = @response.body.chomp.split("\n")
718 ar = @response.body.chomp.split("\n")
709 s2 = ar[1].split(";")[7]
719 s2 = ar[1].split(";")[7]
710 assert_equal '999,9', s2
720 assert_equal '999,9', s2
711
721
712 str_fr = "Fran\xc3\xa7ais"
722 str_fr = "Fran\xc3\xa7ais"
713 if str_fr.respond_to?(:force_encoding)
723 if str_fr.respond_to?(:force_encoding)
714 str_fr.force_encoding('UTF-8')
724 str_fr.force_encoding('UTF-8')
715 end
725 end
716 assert_equal str_fr, l(:general_lang_name)
726 assert_equal str_fr, l(:general_lang_name)
717 assert_equal ';', l(:general_csv_separator)
727 assert_equal ';', l(:general_csv_separator)
718 assert_equal ',', l(:general_csv_decimal_separator)
728 assert_equal ',', l(:general_csv_decimal_separator)
719 end
729 end
720 end
730 end
721 end
731 end
General Comments 0
You need to be logged in to leave comments. Login now