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