##// END OF EJS Templates
Adds a version filter on time entries (#13558)....
Jean-Philippe Lang -
r15264:539166597f99
parent child
Show More
@@ -1,1111 +1,1111
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class QueryColumn
19 19 attr_accessor :name, :sortable, :groupable, :totalable, :default_order
20 20 include Redmine::I18n
21 21
22 22 def initialize(name, options={})
23 23 self.name = name
24 24 self.sortable = options[:sortable]
25 25 self.groupable = options[:groupable] || false
26 26 if groupable == true
27 27 self.groupable = name.to_s
28 28 end
29 29 self.totalable = options[:totalable] || false
30 30 self.default_order = options[:default_order]
31 31 @inline = options.key?(:inline) ? options[:inline] : true
32 32 @caption_key = options[:caption] || "field_#{name}".to_sym
33 33 @frozen = options[:frozen]
34 34 end
35 35
36 36 def caption
37 37 case @caption_key
38 38 when Symbol
39 39 l(@caption_key)
40 40 when Proc
41 41 @caption_key.call
42 42 else
43 43 @caption_key
44 44 end
45 45 end
46 46
47 47 # Returns true if the column is sortable, otherwise false
48 48 def sortable?
49 49 !@sortable.nil?
50 50 end
51 51
52 52 def sortable
53 53 @sortable.is_a?(Proc) ? @sortable.call : @sortable
54 54 end
55 55
56 56 def inline?
57 57 @inline
58 58 end
59 59
60 60 def frozen?
61 61 @frozen
62 62 end
63 63
64 64 def value(object)
65 65 object.send name
66 66 end
67 67
68 68 def value_object(object)
69 69 object.send name
70 70 end
71 71
72 72 def css_classes
73 73 name
74 74 end
75 75 end
76 76
77 77 class QueryCustomFieldColumn < QueryColumn
78 78
79 79 def initialize(custom_field)
80 80 self.name = "cf_#{custom_field.id}".to_sym
81 81 self.sortable = custom_field.order_statement || false
82 82 self.groupable = custom_field.group_statement || false
83 83 self.totalable = custom_field.totalable?
84 84 @inline = true
85 85 @cf = custom_field
86 86 end
87 87
88 88 def caption
89 89 @cf.name
90 90 end
91 91
92 92 def custom_field
93 93 @cf
94 94 end
95 95
96 96 def value_object(object)
97 97 if custom_field.visible_by?(object.project, User.current)
98 98 cv = object.custom_values.select {|v| v.custom_field_id == @cf.id}
99 99 cv.size > 1 ? cv.sort {|a,b| a.value.to_s <=> b.value.to_s} : cv.first
100 100 else
101 101 nil
102 102 end
103 103 end
104 104
105 105 def value(object)
106 106 raw = value_object(object)
107 107 if raw.is_a?(Array)
108 108 raw.map {|r| @cf.cast_value(r.value)}
109 109 elsif raw
110 110 @cf.cast_value(raw.value)
111 111 else
112 112 nil
113 113 end
114 114 end
115 115
116 116 def css_classes
117 117 @css_classes ||= "#{name} #{@cf.field_format}"
118 118 end
119 119 end
120 120
121 121 class QueryAssociationCustomFieldColumn < QueryCustomFieldColumn
122 122
123 123 def initialize(association, custom_field)
124 124 super(custom_field)
125 125 self.name = "#{association}.cf_#{custom_field.id}".to_sym
126 126 # TODO: support sorting/grouping by association custom field
127 127 self.sortable = false
128 128 self.groupable = false
129 129 @association = association
130 130 end
131 131
132 132 def value_object(object)
133 133 if assoc = object.send(@association)
134 134 super(assoc)
135 135 end
136 136 end
137 137
138 138 def css_classes
139 139 @css_classes ||= "#{@association}_cf_#{@cf.id} #{@cf.field_format}"
140 140 end
141 141 end
142 142
143 143 class Query < ActiveRecord::Base
144 144 class StatementInvalid < ::ActiveRecord::StatementInvalid
145 145 end
146 146
147 147 include Redmine::SubclassFactory
148 148
149 149 VISIBILITY_PRIVATE = 0
150 150 VISIBILITY_ROLES = 1
151 151 VISIBILITY_PUBLIC = 2
152 152
153 153 belongs_to :project
154 154 belongs_to :user
155 155 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}queries_roles#{table_name_suffix}", :foreign_key => "query_id"
156 156 serialize :filters
157 157 serialize :column_names
158 158 serialize :sort_criteria, Array
159 159 serialize :options, Hash
160 160
161 161 attr_protected :project_id, :user_id
162 162
163 163 validates_presence_of :name
164 164 validates_length_of :name, :maximum => 255
165 165 validates :visibility, :inclusion => { :in => [VISIBILITY_PUBLIC, VISIBILITY_ROLES, VISIBILITY_PRIVATE] }
166 166 validate :validate_query_filters
167 167 validate do |query|
168 168 errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) if query.visibility == VISIBILITY_ROLES && roles.blank?
169 169 end
170 170
171 171 after_save do |query|
172 172 if query.visibility_changed? && query.visibility != VISIBILITY_ROLES
173 173 query.roles.clear
174 174 end
175 175 end
176 176
177 177 class_attribute :operators
178 178 self.operators = {
179 179 "=" => :label_equals,
180 180 "!" => :label_not_equals,
181 181 "o" => :label_open_issues,
182 182 "c" => :label_closed_issues,
183 183 "!*" => :label_none,
184 184 "*" => :label_any,
185 185 ">=" => :label_greater_or_equal,
186 186 "<=" => :label_less_or_equal,
187 187 "><" => :label_between,
188 188 "<t+" => :label_in_less_than,
189 189 ">t+" => :label_in_more_than,
190 190 "><t+"=> :label_in_the_next_days,
191 191 "t+" => :label_in,
192 192 "t" => :label_today,
193 193 "ld" => :label_yesterday,
194 194 "w" => :label_this_week,
195 195 "lw" => :label_last_week,
196 196 "l2w" => [:label_last_n_weeks, {:count => 2}],
197 197 "m" => :label_this_month,
198 198 "lm" => :label_last_month,
199 199 "y" => :label_this_year,
200 200 ">t-" => :label_less_than_ago,
201 201 "<t-" => :label_more_than_ago,
202 202 "><t-"=> :label_in_the_past_days,
203 203 "t-" => :label_ago,
204 204 "~" => :label_contains,
205 205 "!~" => :label_not_contains,
206 206 "=p" => :label_any_issues_in_project,
207 207 "=!p" => :label_any_issues_not_in_project,
208 208 "!p" => :label_no_issues_in_project,
209 209 "*o" => :label_any_open_issues,
210 210 "!o" => :label_no_open_issues
211 211 }
212 212
213 213 class_attribute :operators_by_filter_type
214 214 self.operators_by_filter_type = {
215 215 :list => [ "=", "!" ],
216 216 :list_status => [ "o", "=", "!", "c", "*" ],
217 217 :list_optional => [ "=", "!", "!*", "*" ],
218 218 :list_subprojects => [ "*", "!*", "=" ],
219 219 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
220 220 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "!*", "*" ],
221 221 :string => [ "=", "~", "!", "!~", "!*", "*" ],
222 222 :text => [ "~", "!~", "!*", "*" ],
223 223 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
224 224 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
225 225 :relation => ["=", "=p", "=!p", "!p", "*o", "!o", "!*", "*"],
226 226 :tree => ["=", "~", "!*", "*"]
227 227 }
228 228
229 229 class_attribute :available_columns
230 230 self.available_columns = []
231 231
232 232 class_attribute :queried_class
233 233
234 234 # Permission required to view the queries, set on subclasses.
235 235 class_attribute :view_permission
236 236
237 237 # Scope of queries that are global or on the given project
238 238 scope :global_or_on_project, lambda {|project|
239 239 where(:project_id => (project.nil? ? nil : [nil, project.id]))
240 240 }
241 241
242 242 scope :sorted, lambda {order(:name, :id)}
243 243
244 244 # Scope of visible queries, can be used from subclasses only.
245 245 # Unlike other visible scopes, a class methods is used as it
246 246 # let handle inheritance more nicely than scope DSL.
247 247 def self.visible(*args)
248 248 if self == ::Query
249 249 # Visibility depends on permissions for each subclass,
250 250 # raise an error if the scope is called from Query (eg. Query.visible)
251 251 raise Exception.new("Cannot call .visible scope from the base Query class, but from subclasses only.")
252 252 end
253 253
254 254 user = args.shift || User.current
255 255 base = Project.allowed_to_condition(user, view_permission, *args)
256 256 scope = joins("LEFT OUTER JOIN #{Project.table_name} ON #{table_name}.project_id = #{Project.table_name}.id").
257 257 where("#{table_name}.project_id IS NULL OR (#{base})")
258 258
259 259 if user.admin?
260 260 scope.where("#{table_name}.visibility <> ? OR #{table_name}.user_id = ?", VISIBILITY_PRIVATE, user.id)
261 261 elsif user.memberships.any?
262 262 scope.where("#{table_name}.visibility = ?" +
263 263 " OR (#{table_name}.visibility = ? AND #{table_name}.id IN (" +
264 264 "SELECT DISTINCT q.id FROM #{table_name} q" +
265 265 " INNER JOIN #{table_name_prefix}queries_roles#{table_name_suffix} qr on qr.query_id = q.id" +
266 266 " INNER JOIN #{MemberRole.table_name} mr ON mr.role_id = qr.role_id" +
267 267 " INNER JOIN #{Member.table_name} m ON m.id = mr.member_id AND m.user_id = ?" +
268 268 " WHERE q.project_id IS NULL OR q.project_id = m.project_id))" +
269 269 " OR #{table_name}.user_id = ?",
270 270 VISIBILITY_PUBLIC, VISIBILITY_ROLES, user.id, user.id)
271 271 elsif user.logged?
272 272 scope.where("#{table_name}.visibility = ? OR #{table_name}.user_id = ?", VISIBILITY_PUBLIC, user.id)
273 273 else
274 274 scope.where("#{table_name}.visibility = ?", VISIBILITY_PUBLIC)
275 275 end
276 276 end
277 277
278 278 # Returns true if the query is visible to +user+ or the current user.
279 279 def visible?(user=User.current)
280 280 return true if user.admin?
281 281 return false unless project.nil? || user.allowed_to?(self.class.view_permission, project)
282 282 case visibility
283 283 when VISIBILITY_PUBLIC
284 284 true
285 285 when VISIBILITY_ROLES
286 286 if project
287 287 (user.roles_for_project(project) & roles).any?
288 288 else
289 289 Member.where(:user_id => user.id).joins(:roles).where(:member_roles => {:role_id => roles.map(&:id)}).any?
290 290 end
291 291 else
292 292 user == self.user
293 293 end
294 294 end
295 295
296 296 def is_private?
297 297 visibility == VISIBILITY_PRIVATE
298 298 end
299 299
300 300 def is_public?
301 301 !is_private?
302 302 end
303 303
304 304 def queried_table_name
305 305 @queried_table_name ||= self.class.queried_class.table_name
306 306 end
307 307
308 308 def initialize(attributes=nil, *args)
309 309 super attributes
310 310 @is_for_all = project.nil?
311 311 end
312 312
313 313 # Builds the query from the given params
314 314 def build_from_params(params)
315 315 if params[:fields] || params[:f]
316 316 self.filters = {}
317 317 add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v])
318 318 else
319 319 available_filters.keys.each do |field|
320 320 add_short_filter(field, params[field]) if params[field]
321 321 end
322 322 end
323 323 self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
324 324 self.column_names = params[:c] || (params[:query] && params[:query][:column_names])
325 325 self.totalable_names = params[:t] || (params[:query] && params[:query][:totalable_names])
326 326 self
327 327 end
328 328
329 329 # Builds a new query from the given params and attributes
330 330 def self.build_from_params(params, attributes={})
331 331 new(attributes).build_from_params(params)
332 332 end
333 333
334 334 def validate_query_filters
335 335 filters.each_key do |field|
336 336 if values_for(field)
337 337 case type_for(field)
338 338 when :integer
339 339 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/\A[+-]?\d+(,[+-]?\d+)*\z/) }
340 340 when :float
341 341 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/\A[+-]?\d+(\.\d*)?\z/) }
342 342 when :date, :date_past
343 343 case operator_for(field)
344 344 when "=", ">=", "<=", "><"
345 345 add_filter_error(field, :invalid) if values_for(field).detect {|v|
346 346 v.present? && (!v.match(/\A\d{4}-\d{2}-\d{2}(T\d{2}((:)?\d{2}){0,2}(Z|\d{2}:?\d{2})?)?\z/) || parse_date(v).nil?)
347 347 }
348 348 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
349 349 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
350 350 end
351 351 end
352 352 end
353 353
354 354 add_filter_error(field, :blank) unless
355 355 # filter requires one or more values
356 356 (values_for(field) and !values_for(field).first.blank?) or
357 357 # filter doesn't require any value
358 358 ["o", "c", "!*", "*", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "*o", "!o"].include? operator_for(field)
359 359 end if filters
360 360 end
361 361
362 362 def add_filter_error(field, message)
363 363 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
364 364 errors.add(:base, m)
365 365 end
366 366
367 367 def editable_by?(user)
368 368 return false unless user
369 369 # Admin can edit them all and regular users can edit their private queries
370 370 return true if user.admin? || (is_private? && self.user_id == user.id)
371 371 # Members can not edit public queries that are for all project (only admin is allowed to)
372 372 is_public? && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
373 373 end
374 374
375 375 def trackers
376 376 @trackers ||= (project.nil? ? Tracker.all : project.rolled_up_trackers).visible.sorted
377 377 end
378 378
379 379 # Returns a hash of localized labels for all filter operators
380 380 def self.operators_labels
381 381 operators.inject({}) {|h, operator| h[operator.first] = l(*operator.last); h}
382 382 end
383 383
384 384 # Returns a representation of the available filters for JSON serialization
385 385 def available_filters_as_json
386 386 json = {}
387 387 available_filters.each do |field, options|
388 388 options = options.slice(:type, :name, :values)
389 389 if options[:values] && values_for(field)
390 390 missing = Array(values_for(field)).select(&:present?) - options[:values].map(&:last)
391 391 if missing.any? && respond_to?(method = "find_#{field}_filter_values")
392 392 options[:values] += send(method, missing)
393 393 end
394 394 end
395 395 json[field] = options.stringify_keys
396 396 end
397 397 json
398 398 end
399 399
400 400 def all_projects
401 401 @all_projects ||= Project.visible.to_a
402 402 end
403 403
404 404 def all_projects_values
405 405 return @all_projects_values if @all_projects_values
406 406
407 407 values = []
408 408 Project.project_tree(all_projects) do |p, level|
409 409 prefix = (level > 0 ? ('--' * level + ' ') : '')
410 410 values << ["#{prefix}#{p.name}", p.id.to_s]
411 411 end
412 412 @all_projects_values = values
413 413 end
414 414
415 415 # Adds available filters
416 416 def initialize_available_filters
417 417 # implemented by sub-classes
418 418 end
419 419 protected :initialize_available_filters
420 420
421 421 # Adds an available filter
422 422 def add_available_filter(field, options)
423 423 @available_filters ||= ActiveSupport::OrderedHash.new
424 424 @available_filters[field] = options
425 425 @available_filters
426 426 end
427 427
428 428 # Removes an available filter
429 429 def delete_available_filter(field)
430 430 if @available_filters
431 431 @available_filters.delete(field)
432 432 end
433 433 end
434 434
435 435 # Return a hash of available filters
436 436 def available_filters
437 437 unless @available_filters
438 438 initialize_available_filters
439 439 @available_filters.each do |field, options|
440 440 options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
441 441 end
442 442 end
443 443 @available_filters
444 444 end
445 445
446 446 def add_filter(field, operator, values=nil)
447 447 # values must be an array
448 448 return unless values.nil? || values.is_a?(Array)
449 449 # check if field is defined as an available filter
450 450 if available_filters.has_key? field
451 451 filter_options = available_filters[field]
452 452 filters[field] = {:operator => operator, :values => (values || [''])}
453 453 end
454 454 end
455 455
456 456 def add_short_filter(field, expression)
457 457 return unless expression && available_filters.has_key?(field)
458 458 field_type = available_filters[field][:type]
459 459 operators_by_filter_type[field_type].sort.reverse.detect do |operator|
460 460 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
461 461 values = $1
462 462 add_filter field, operator, values.present? ? values.split('|') : ['']
463 463 end || add_filter(field, '=', expression.to_s.split('|'))
464 464 end
465 465
466 466 # Add multiple filters using +add_filter+
467 467 def add_filters(fields, operators, values)
468 468 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
469 469 fields.each do |field|
470 470 add_filter(field, operators[field], values && values[field])
471 471 end
472 472 end
473 473 end
474 474
475 475 def has_filter?(field)
476 476 filters and filters[field]
477 477 end
478 478
479 479 def type_for(field)
480 480 available_filters[field][:type] if available_filters.has_key?(field)
481 481 end
482 482
483 483 def operator_for(field)
484 484 has_filter?(field) ? filters[field][:operator] : nil
485 485 end
486 486
487 487 def values_for(field)
488 488 has_filter?(field) ? filters[field][:values] : nil
489 489 end
490 490
491 491 def value_for(field, index=0)
492 492 (values_for(field) || [])[index]
493 493 end
494 494
495 495 def label_for(field)
496 496 label = available_filters[field][:name] if available_filters.has_key?(field)
497 497 label ||= queried_class.human_attribute_name(field, :default => field)
498 498 end
499 499
500 500 def self.add_available_column(column)
501 501 self.available_columns << (column) if column.is_a?(QueryColumn)
502 502 end
503 503
504 504 # Returns an array of columns that can be used to group the results
505 505 def groupable_columns
506 506 available_columns.select {|c| c.groupable}
507 507 end
508 508
509 509 # Returns a Hash of columns and the key for sorting
510 510 def sortable_columns
511 511 available_columns.inject({}) {|h, column|
512 512 h[column.name.to_s] = column.sortable
513 513 h
514 514 }
515 515 end
516 516
517 517 def columns
518 518 # preserve the column_names order
519 519 cols = (has_default_columns? ? default_columns_names : column_names).collect do |name|
520 520 available_columns.find { |col| col.name == name }
521 521 end.compact
522 522 available_columns.select(&:frozen?) | cols
523 523 end
524 524
525 525 def inline_columns
526 526 columns.select(&:inline?)
527 527 end
528 528
529 529 def block_columns
530 530 columns.reject(&:inline?)
531 531 end
532 532
533 533 def available_inline_columns
534 534 available_columns.select(&:inline?)
535 535 end
536 536
537 537 def available_block_columns
538 538 available_columns.reject(&:inline?)
539 539 end
540 540
541 541 def available_totalable_columns
542 542 available_columns.select(&:totalable)
543 543 end
544 544
545 545 def default_columns_names
546 546 []
547 547 end
548 548
549 549 def column_names=(names)
550 550 if names
551 551 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
552 552 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
553 553 # Set column_names to nil if default columns
554 554 if names == default_columns_names
555 555 names = nil
556 556 end
557 557 end
558 558 write_attribute(:column_names, names)
559 559 end
560 560
561 561 def has_column?(column)
562 562 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
563 563 end
564 564
565 565 def has_custom_field_column?
566 566 columns.any? {|column| column.is_a? QueryCustomFieldColumn}
567 567 end
568 568
569 569 def has_default_columns?
570 570 column_names.nil? || column_names.empty?
571 571 end
572 572
573 573 def totalable_columns
574 574 names = totalable_names
575 575 available_totalable_columns.select {|column| names.include?(column.name)}
576 576 end
577 577
578 578 def totalable_names=(names)
579 579 if names
580 580 names = names.select(&:present?).map {|n| n.is_a?(Symbol) ? n : n.to_sym}
581 581 end
582 582 options[:totalable_names] = names
583 583 end
584 584
585 585 def totalable_names
586 586 options[:totalable_names] || Setting.issue_list_default_totals.map(&:to_sym) || []
587 587 end
588 588
589 589 def sort_criteria=(arg)
590 590 c = []
591 591 if arg.is_a?(Hash)
592 592 arg = arg.keys.sort.collect {|k| arg[k]}
593 593 end
594 594 if arg
595 595 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
596 596 end
597 597 write_attribute(:sort_criteria, c)
598 598 end
599 599
600 600 def sort_criteria
601 601 read_attribute(:sort_criteria) || []
602 602 end
603 603
604 604 def sort_criteria_key(arg)
605 605 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
606 606 end
607 607
608 608 def sort_criteria_order(arg)
609 609 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
610 610 end
611 611
612 612 def sort_criteria_order_for(key)
613 613 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
614 614 end
615 615
616 616 # Returns the SQL sort order that should be prepended for grouping
617 617 def group_by_sort_order
618 618 if column = group_by_column
619 619 order = (sort_criteria_order_for(column.name) || column.default_order).try(:upcase)
620 620 Array(column.sortable).map {|s| "#{s} #{order}"}
621 621 end
622 622 end
623 623
624 624 # Returns true if the query is a grouped query
625 625 def grouped?
626 626 !group_by_column.nil?
627 627 end
628 628
629 629 def group_by_column
630 630 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
631 631 end
632 632
633 633 def group_by_statement
634 634 group_by_column.try(:groupable)
635 635 end
636 636
637 637 def project_statement
638 638 project_clauses = []
639 639 if project && !project.descendants.active.empty?
640 640 if has_filter?("subproject_id")
641 641 case operator_for("subproject_id")
642 642 when '='
643 643 # include the selected subprojects
644 644 ids = [project.id] + values_for("subproject_id").each(&:to_i)
645 645 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
646 646 when '!*'
647 647 # main project only
648 648 project_clauses << "#{Project.table_name}.id = %d" % project.id
649 649 else
650 650 # all subprojects
651 651 project_clauses << "#{Project.table_name}.lft >= #{project.lft} AND #{Project.table_name}.rgt <= #{project.rgt}"
652 652 end
653 653 elsif Setting.display_subprojects_issues?
654 654 project_clauses << "#{Project.table_name}.lft >= #{project.lft} AND #{Project.table_name}.rgt <= #{project.rgt}"
655 655 else
656 656 project_clauses << "#{Project.table_name}.id = %d" % project.id
657 657 end
658 658 elsif project
659 659 project_clauses << "#{Project.table_name}.id = %d" % project.id
660 660 end
661 661 project_clauses.any? ? project_clauses.join(' AND ') : nil
662 662 end
663 663
664 664 def statement
665 665 # filters clauses
666 666 filters_clauses = []
667 667 filters.each_key do |field|
668 668 next if field == "subproject_id"
669 669 v = values_for(field).clone
670 670 next unless v and !v.empty?
671 671 operator = operator_for(field)
672 672
673 673 # "me" value substitution
674 674 if %w(assigned_to_id author_id user_id watcher_id).include?(field)
675 675 if v.delete("me")
676 676 if User.current.logged?
677 677 v.push(User.current.id.to_s)
678 678 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
679 679 else
680 680 v.push("0")
681 681 end
682 682 end
683 683 end
684 684
685 685 if field == 'project_id'
686 686 if v.delete('mine')
687 687 v += User.current.memberships.map(&:project_id).map(&:to_s)
688 688 end
689 689 end
690 690
691 691 if field =~ /cf_(\d+)$/
692 692 # custom field
693 693 filters_clauses << sql_for_custom_field(field, operator, v, $1)
694 elsif respond_to?("sql_for_#{field}_field")
694 elsif respond_to?(method = "sql_for_#{field.gsub('.','_')}_field")
695 695 # specific statement
696 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
696 filters_clauses << send(method, field, operator, v)
697 697 else
698 698 # regular field
699 699 filters_clauses << '(' + sql_for_field(field, operator, v, queried_table_name, field) + ')'
700 700 end
701 701 end if filters and valid?
702 702
703 703 if (c = group_by_column) && c.is_a?(QueryCustomFieldColumn)
704 704 # Excludes results for which the grouped custom field is not visible
705 705 filters_clauses << c.custom_field.visibility_by_project_condition
706 706 end
707 707
708 708 filters_clauses << project_statement
709 709 filters_clauses.reject!(&:blank?)
710 710
711 711 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
712 712 end
713 713
714 714 # Returns the sum of values for the given column
715 715 def total_for(column)
716 716 total_with_scope(column, base_scope)
717 717 end
718 718
719 719 # Returns a hash of the sum of the given column for each group,
720 720 # or nil if the query is not grouped
721 721 def total_by_group_for(column)
722 722 grouped_query do |scope|
723 723 total_with_scope(column, scope)
724 724 end
725 725 end
726 726
727 727 def totals
728 728 totals = totalable_columns.map {|column| [column, total_for(column)]}
729 729 yield totals if block_given?
730 730 totals
731 731 end
732 732
733 733 def totals_by_group
734 734 totals = totalable_columns.map {|column| [column, total_by_group_for(column)]}
735 735 yield totals if block_given?
736 736 totals
737 737 end
738 738
739 739 private
740 740
741 741 def grouped_query(&block)
742 742 r = nil
743 743 if grouped?
744 744 begin
745 745 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
746 746 r = yield base_group_scope
747 747 rescue ActiveRecord::RecordNotFound
748 748 r = {nil => yield(base_scope)}
749 749 end
750 750 c = group_by_column
751 751 if c.is_a?(QueryCustomFieldColumn)
752 752 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
753 753 end
754 754 end
755 755 r
756 756 rescue ::ActiveRecord::StatementInvalid => e
757 757 raise StatementInvalid.new(e.message)
758 758 end
759 759
760 760 def total_with_scope(column, scope)
761 761 unless column.is_a?(QueryColumn)
762 762 column = column.to_sym
763 763 column = available_totalable_columns.detect {|c| c.name == column}
764 764 end
765 765 if column.is_a?(QueryCustomFieldColumn)
766 766 custom_field = column.custom_field
767 767 send "total_for_custom_field", custom_field, scope
768 768 else
769 769 send "total_for_#{column.name}", scope
770 770 end
771 771 rescue ::ActiveRecord::StatementInvalid => e
772 772 raise StatementInvalid.new(e.message)
773 773 end
774 774
775 775 def base_scope
776 776 raise "unimplemented"
777 777 end
778 778
779 779 def base_group_scope
780 780 base_scope.
781 781 joins(joins_for_order_statement(group_by_statement)).
782 782 group(group_by_statement)
783 783 end
784 784
785 785 def total_for_custom_field(custom_field, scope, &block)
786 786 total = custom_field.format.total_for_scope(custom_field, scope)
787 787 total = map_total(total) {|t| custom_field.format.cast_total_value(custom_field, t)}
788 788 total
789 789 end
790 790
791 791 def map_total(total, &block)
792 792 if total.is_a?(Hash)
793 793 total.keys.each {|k| total[k] = yield total[k]}
794 794 else
795 795 total = yield total
796 796 end
797 797 total
798 798 end
799 799
800 800 def sql_for_custom_field(field, operator, value, custom_field_id)
801 801 db_table = CustomValue.table_name
802 802 db_field = 'value'
803 803 filter = @available_filters[field]
804 804 return nil unless filter
805 805 if filter[:field].format.target_class && filter[:field].format.target_class <= User
806 806 if value.delete('me')
807 807 value.push User.current.id.to_s
808 808 end
809 809 end
810 810 not_in = nil
811 811 if operator == '!'
812 812 # Makes ! operator work for custom fields with multiple values
813 813 operator = '='
814 814 not_in = 'NOT'
815 815 end
816 816 customized_key = "id"
817 817 customized_class = queried_class
818 818 if field =~ /^(.+)\.cf_/
819 819 assoc = $1
820 820 customized_key = "#{assoc}_id"
821 821 customized_class = queried_class.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
822 822 raise "Unknown #{queried_class.name} association #{assoc}" unless customized_class
823 823 end
824 824 where = sql_for_field(field, operator, value, db_table, db_field, true)
825 825 if operator =~ /[<>]/
826 826 where = "(#{where}) AND #{db_table}.#{db_field} <> ''"
827 827 end
828 828 "#{queried_table_name}.#{customized_key} #{not_in} IN (" +
829 829 "SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name}" +
830 830 " 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}" +
831 831 " WHERE (#{where}) AND (#{filter[:field].visibility_by_project_condition}))"
832 832 end
833 833
834 834 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
835 835 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
836 836 sql = ''
837 837 case operator
838 838 when "="
839 839 if value.any?
840 840 case type_for(field)
841 841 when :date, :date_past
842 842 sql = date_clause(db_table, db_field, parse_date(value.first), parse_date(value.first), is_custom_filter)
843 843 when :integer
844 844 int_values = value.first.to_s.scan(/[+-]?\d+/).map(&:to_i).join(",")
845 845 if int_values.present?
846 846 if is_custom_filter
847 847 sql = "(#{db_table}.#{db_field} <> '' AND CAST(CASE #{db_table}.#{db_field} WHEN '' THEN '0' ELSE #{db_table}.#{db_field} END AS decimal(30,3)) IN (#{int_values}))"
848 848 else
849 849 sql = "#{db_table}.#{db_field} IN (#{int_values})"
850 850 end
851 851 else
852 852 sql = "1=0"
853 853 end
854 854 when :float
855 855 if is_custom_filter
856 856 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})"
857 857 else
858 858 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
859 859 end
860 860 else
861 861 sql = queried_class.send(:sanitize_sql_for_conditions, ["#{db_table}.#{db_field} IN (?)", value])
862 862 end
863 863 else
864 864 # IN an empty set
865 865 sql = "1=0"
866 866 end
867 867 when "!"
868 868 if value.any?
869 869 sql = queried_class.send(:sanitize_sql_for_conditions, ["(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (?))", value])
870 870 else
871 871 # NOT IN an empty set
872 872 sql = "1=1"
873 873 end
874 874 when "!*"
875 875 sql = "#{db_table}.#{db_field} IS NULL"
876 876 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
877 877 when "*"
878 878 sql = "#{db_table}.#{db_field} IS NOT NULL"
879 879 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
880 880 when ">="
881 881 if [:date, :date_past].include?(type_for(field))
882 882 sql = date_clause(db_table, db_field, parse_date(value.first), nil, is_custom_filter)
883 883 else
884 884 if is_custom_filter
885 885 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})"
886 886 else
887 887 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
888 888 end
889 889 end
890 890 when "<="
891 891 if [:date, :date_past].include?(type_for(field))
892 892 sql = date_clause(db_table, db_field, nil, parse_date(value.first), is_custom_filter)
893 893 else
894 894 if is_custom_filter
895 895 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})"
896 896 else
897 897 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
898 898 end
899 899 end
900 900 when "><"
901 901 if [:date, :date_past].include?(type_for(field))
902 902 sql = date_clause(db_table, db_field, parse_date(value[0]), parse_date(value[1]), is_custom_filter)
903 903 else
904 904 if is_custom_filter
905 905 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})"
906 906 else
907 907 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
908 908 end
909 909 end
910 910 when "o"
911 911 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{self.class.connection.quoted_false})" if field == "status_id"
912 912 when "c"
913 913 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{self.class.connection.quoted_true})" if field == "status_id"
914 914 when "><t-"
915 915 # between today - n days and today
916 916 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0, is_custom_filter)
917 917 when ">t-"
918 918 # >= today - n days
919 919 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil, is_custom_filter)
920 920 when "<t-"
921 921 # <= today - n days
922 922 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i, is_custom_filter)
923 923 when "t-"
924 924 # = n days in past
925 925 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i, is_custom_filter)
926 926 when "><t+"
927 927 # between today and today + n days
928 928 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i, is_custom_filter)
929 929 when ">t+"
930 930 # >= today + n days
931 931 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil, is_custom_filter)
932 932 when "<t+"
933 933 # <= today + n days
934 934 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i, is_custom_filter)
935 935 when "t+"
936 936 # = today + n days
937 937 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i, is_custom_filter)
938 938 when "t"
939 939 # = today
940 940 sql = relative_date_clause(db_table, db_field, 0, 0, is_custom_filter)
941 941 when "ld"
942 942 # = yesterday
943 943 sql = relative_date_clause(db_table, db_field, -1, -1, is_custom_filter)
944 944 when "w"
945 945 # = this week
946 946 first_day_of_week = l(:general_first_day_of_week).to_i
947 947 day_of_week = User.current.today.cwday
948 948 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
949 949 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6, is_custom_filter)
950 950 when "lw"
951 951 # = last week
952 952 first_day_of_week = l(:general_first_day_of_week).to_i
953 953 day_of_week = User.current.today.cwday
954 954 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
955 955 sql = relative_date_clause(db_table, db_field, - days_ago - 7, - days_ago - 1, is_custom_filter)
956 956 when "l2w"
957 957 # = last 2 weeks
958 958 first_day_of_week = l(:general_first_day_of_week).to_i
959 959 day_of_week = User.current.today.cwday
960 960 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
961 961 sql = relative_date_clause(db_table, db_field, - days_ago - 14, - days_ago - 1, is_custom_filter)
962 962 when "m"
963 963 # = this month
964 964 date = User.current.today
965 965 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month, is_custom_filter)
966 966 when "lm"
967 967 # = last month
968 968 date = User.current.today.prev_month
969 969 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month, is_custom_filter)
970 970 when "y"
971 971 # = this year
972 972 date = User.current.today
973 973 sql = date_clause(db_table, db_field, date.beginning_of_year, date.end_of_year, is_custom_filter)
974 974 when "~"
975 975 sql = sql_contains("#{db_table}.#{db_field}", value.first)
976 976 when "!~"
977 977 sql = sql_contains("#{db_table}.#{db_field}", value.first, false)
978 978 else
979 979 raise "Unknown query operator #{operator}"
980 980 end
981 981
982 982 return sql
983 983 end
984 984
985 985 # Returns a SQL LIKE statement with wildcards
986 986 def sql_contains(db_field, value, match=true)
987 987 queried_class.send :sanitize_sql_for_conditions,
988 988 [Redmine::Database.like(db_field, '?', :match => match), "%#{value}%"]
989 989 end
990 990
991 991 # Adds a filter for the given custom field
992 992 def add_custom_field_filter(field, assoc=nil)
993 993 options = field.query_filter_options(self)
994 994 if field.format.target_class && field.format.target_class <= User
995 995 if options[:values].is_a?(Array) && User.current.logged?
996 996 options[:values].unshift ["<< #{l(:label_me)} >>", "me"]
997 997 end
998 998 end
999 999
1000 1000 filter_id = "cf_#{field.id}"
1001 1001 filter_name = field.name
1002 1002 if assoc.present?
1003 1003 filter_id = "#{assoc}.#{filter_id}"
1004 1004 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
1005 1005 end
1006 1006 add_available_filter filter_id, options.merge({
1007 1007 :name => filter_name,
1008 1008 :field => field
1009 1009 })
1010 1010 end
1011 1011
1012 1012 # Adds filters for the given custom fields scope
1013 1013 def add_custom_fields_filters(scope, assoc=nil)
1014 1014 scope.visible.where(:is_filter => true).sorted.each do |field|
1015 1015 add_custom_field_filter(field, assoc)
1016 1016 end
1017 1017 end
1018 1018
1019 1019 # Adds filters for the given associations custom fields
1020 1020 def add_associations_custom_fields_filters(*associations)
1021 1021 fields_by_class = CustomField.visible.where(:is_filter => true).group_by(&:class)
1022 1022 associations.each do |assoc|
1023 1023 association_klass = queried_class.reflect_on_association(assoc).klass
1024 1024 fields_by_class.each do |field_class, fields|
1025 1025 if field_class.customized_class <= association_klass
1026 1026 fields.sort.each do |field|
1027 1027 add_custom_field_filter(field, assoc)
1028 1028 end
1029 1029 end
1030 1030 end
1031 1031 end
1032 1032 end
1033 1033
1034 1034 def quoted_time(time, is_custom_filter)
1035 1035 if is_custom_filter
1036 1036 # Custom field values are stored as strings in the DB
1037 1037 # using this format that does not depend on DB date representation
1038 1038 time.strftime("%Y-%m-%d %H:%M:%S")
1039 1039 else
1040 1040 self.class.connection.quoted_date(time)
1041 1041 end
1042 1042 end
1043 1043
1044 1044 def date_for_user_time_zone(y, m, d)
1045 1045 if tz = User.current.time_zone
1046 1046 tz.local y, m, d
1047 1047 else
1048 1048 Time.local y, m, d
1049 1049 end
1050 1050 end
1051 1051
1052 1052 # Returns a SQL clause for a date or datetime field.
1053 1053 def date_clause(table, field, from, to, is_custom_filter)
1054 1054 s = []
1055 1055 if from
1056 1056 if from.is_a?(Date)
1057 1057 from = date_for_user_time_zone(from.year, from.month, from.day).yesterday.end_of_day
1058 1058 else
1059 1059 from = from - 1 # second
1060 1060 end
1061 1061 if self.class.default_timezone == :utc
1062 1062 from = from.utc
1063 1063 end
1064 1064 s << ("#{table}.#{field} > '%s'" % [quoted_time(from, is_custom_filter)])
1065 1065 end
1066 1066 if to
1067 1067 if to.is_a?(Date)
1068 1068 to = date_for_user_time_zone(to.year, to.month, to.day).end_of_day
1069 1069 end
1070 1070 if self.class.default_timezone == :utc
1071 1071 to = to.utc
1072 1072 end
1073 1073 s << ("#{table}.#{field} <= '%s'" % [quoted_time(to, is_custom_filter)])
1074 1074 end
1075 1075 s.join(' AND ')
1076 1076 end
1077 1077
1078 1078 # Returns a SQL clause for a date or datetime field using relative dates.
1079 1079 def relative_date_clause(table, field, days_from, days_to, is_custom_filter)
1080 1080 date_clause(table, field, (days_from ? User.current.today + days_from : nil), (days_to ? User.current.today + days_to : nil), is_custom_filter)
1081 1081 end
1082 1082
1083 1083 # Returns a Date or Time from the given filter value
1084 1084 def parse_date(arg)
1085 1085 if arg.to_s =~ /\A\d{4}-\d{2}-\d{2}T/
1086 1086 Time.parse(arg) rescue nil
1087 1087 else
1088 1088 Date.parse(arg) rescue nil
1089 1089 end
1090 1090 end
1091 1091
1092 1092 # Additional joins required for the given sort options
1093 1093 def joins_for_order_statement(order_options)
1094 1094 joins = []
1095 1095
1096 1096 if order_options
1097 1097 if order_options.include?('authors')
1098 1098 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{queried_table_name}.author_id"
1099 1099 end
1100 1100 order_options.scan(/cf_\d+/).uniq.each do |name|
1101 1101 column = available_columns.detect {|c| c.name.to_s == name}
1102 1102 join = column && column.custom_field.join_for_order_statement
1103 1103 if join
1104 1104 joins << join
1105 1105 end
1106 1106 end
1107 1107 end
1108 1108
1109 1109 joins.any? ? joins.join(' ') : nil
1110 1110 end
1111 1111 end
@@ -1,163 +1,187
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class TimeEntryQuery < Query
19 19
20 20 self.queried_class = TimeEntry
21 21 self.view_permission = :view_time_entries
22 22
23 23 self.available_columns = [
24 24 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
25 25 QueryColumn.new(:spent_on, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :default_order => 'desc', :groupable => true),
26 26 QueryColumn.new(:tweek, :sortable => ["#{TimeEntry.table_name}.spent_on", "#{TimeEntry.table_name}.created_on"], :caption => l(:label_week)),
27 27 QueryColumn.new(:user, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
28 28 QueryColumn.new(:activity, :sortable => "#{TimeEntryActivity.table_name}.position", :groupable => true),
29 29 QueryColumn.new(:issue, :sortable => "#{Issue.table_name}.id"),
30 30 QueryColumn.new(:comments),
31 31 QueryColumn.new(:hours, :sortable => "#{TimeEntry.table_name}.hours"),
32 32 ]
33 33
34 34 def initialize(attributes=nil, *args)
35 35 super attributes
36 36 self.filters ||= {}
37 37 add_filter('spent_on', '*') unless filters.present?
38 38 end
39 39
40 40 def initialize_available_filters
41 41 add_available_filter "spent_on", :type => :date_past
42 42
43 43 principals = []
44 versions = []
44 45 if project
45 46 principals += project.principals.visible.sort
46 47 unless project.leaf?
47 48 subprojects = project.descendants.visible.to_a
48 49 if subprojects.any?
49 50 add_available_filter "subproject_id",
50 51 :type => :list_subprojects,
51 52 :values => subprojects.collect{|s| [s.name, s.id.to_s] }
52 53 principals += Principal.member_of(subprojects).visible
53 54 end
54 55 end
56 versions = project.shared_versions.to_a
55 57 else
56 58 if all_projects.any?
57 59 # members of visible projects
58 60 principals += Principal.member_of(all_projects).visible
59 61 # project filter
60 62 project_values = []
61 63 if User.current.logged? && User.current.memberships.any?
62 64 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
63 65 end
64 66 project_values += all_projects_values
65 67 add_available_filter("project_id",
66 68 :type => :list, :values => project_values
67 69 ) unless project_values.empty?
68 70 end
69 71 end
70 72
71 73 add_available_filter("issue_id", :type => :tree, :label => :label_issue)
74 add_available_filter("issue.fixed_version_id",
75 :type => :list,
76 :name => l("label_attribute_of_issue", :name => l(:field_fixed_version)),
77 :values => Version.sort_by_status(versions).collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s, l("version_status_#{s.status}")] })
72 78
73 79 principals.uniq!
74 80 principals.sort!
75 81 users = principals.select {|p| p.is_a?(User)}
76 82
77 83 users_values = []
78 84 users_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
79 85 users_values += users.collect{|s| [s.name, s.id.to_s] }
80 86 add_available_filter("user_id",
81 87 :type => :list_optional, :values => users_values
82 88 ) unless users_values.empty?
83 89
84 90 activities = (project ? project.activities : TimeEntryActivity.shared)
85 91 add_available_filter("activity_id",
86 92 :type => :list, :values => activities.map {|a| [a.name, a.id.to_s]}
87 93 ) unless activities.empty?
88 94
89 95 add_available_filter "comments", :type => :text
90 96 add_available_filter "hours", :type => :float
91 97
92 98 add_custom_fields_filters(TimeEntryCustomField)
93 99 add_associations_custom_fields_filters :project, :issue, :user
94 100 end
95 101
96 102 def available_columns
97 103 return @available_columns if @available_columns
98 104 @available_columns = self.class.available_columns.dup
99 105 @available_columns += TimeEntryCustomField.visible.
100 106 map {|cf| QueryCustomFieldColumn.new(cf) }
101 107 @available_columns += IssueCustomField.visible.
102 108 map {|cf| QueryAssociationCustomFieldColumn.new(:issue, cf) }
103 109 @available_columns
104 110 end
105 111
106 112 def default_columns_names
107 113 @default_columns_names ||= [:project, :spent_on, :user, :activity, :issue, :comments, :hours]
108 114 end
109 115
110 116 def results_scope(options={})
111 117 order_option = [group_by_sort_order, options[:order]].flatten.reject(&:blank?)
112 118
113 119 TimeEntry.visible.
114 120 where(statement).
115 121 order(order_option).
116 122 joins(joins_for_order_statement(order_option.join(','))).
117 123 includes(:activity).
118 124 references(:activity)
119 125 end
120 126
121 127 def sql_for_issue_id_field(field, operator, value)
122 128 case operator
123 129 when "="
124 130 "#{TimeEntry.table_name}.issue_id = #{value.first.to_i}"
125 131 when "~"
126 132 issue = Issue.where(:id => value.first.to_i).first
127 133 if issue && (issue_ids = issue.self_and_descendants.pluck(:id)).any?
128 134 "#{TimeEntry.table_name}.issue_id IN (#{issue_ids.join(',')})"
129 135 else
130 136 "1=0"
131 137 end
132 138 when "!*"
133 139 "#{TimeEntry.table_name}.issue_id IS NULL"
134 140 when "*"
135 141 "#{TimeEntry.table_name}.issue_id IS NOT NULL"
136 142 end
137 143 end
138 144
145 def sql_for_issue_fixed_version_id_field(field, operator, value)
146 issue_ids = Issue.where(:fixed_version_id => value.first.to_i).pluck(:id)
147 case operator
148 when "="
149 if issue_ids.any?
150 "#{TimeEntry.table_name}.issue_id IN (#{issue_ids.join(',')})"
151 else
152 "1=0"
153 end
154 when "!"
155 if issue_ids.any?
156 "#{TimeEntry.table_name}.issue_id NOT IN (#{issue_ids.join(',')})"
157 else
158 "1=1"
159 end
160 end
161 end
162
139 163 def sql_for_activity_id_field(field, operator, value)
140 164 condition_on_id = sql_for_field(field, operator, value, Enumeration.table_name, 'id')
141 165 condition_on_parent_id = sql_for_field(field, operator, value, Enumeration.table_name, 'parent_id')
142 166 ids = value.map(&:to_i).join(',')
143 167 table_name = Enumeration.table_name
144 168 if operator == '='
145 169 "(#{table_name}.id IN (#{ids}) OR #{table_name}.parent_id IN (#{ids}))"
146 170 else
147 171 "(#{table_name}.id NOT IN (#{ids}) AND (#{table_name}.parent_id IS NULL OR #{table_name}.parent_id NOT IN (#{ids})))"
148 172 end
149 173 end
150 174
151 175 # Accepts :from/:to params as shortcut filters
152 176 def build_from_params(params)
153 177 super
154 178 if params[:from].present? && params[:to].present?
155 179 add_filter('spent_on', '><', [params[:from], params[:to]])
156 180 elsif params[:from].present?
157 181 add_filter('spent_on', '>=', [params[:from]])
158 182 elsif params[:to].present?
159 183 add_filter('spent_on', '<=', [params[:to]])
160 184 end
161 185 self
162 186 end
163 187 end
@@ -1,823 +1,844
1 1 # -*- coding: utf-8 -*-
2 2 # Redmine - project management software
3 3 # Copyright (C) 2006-2016 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 :projects_trackers, :custom_fields_trackers,
27 27 :custom_fields_projects
28 28
29 29 include Redmine::I18n
30 30
31 31 def test_new
32 32 @request.session[:user_id] = 3
33 33 get :new
34 34 assert_response :success
35 35 assert_template 'new'
36 36 assert_select 'input[name=?][type=hidden]', 'project_id', 0
37 37 assert_select 'input[name=?][type=hidden]', 'issue_id', 0
38 38 assert_select 'select[name=?]', 'time_entry[project_id]' do
39 39 # blank option for project
40 40 assert_select 'option[value=""]'
41 41 end
42 42 end
43 43
44 44 def test_new_with_project_id
45 45 @request.session[:user_id] = 3
46 46 get :new, :project_id => 1
47 47 assert_response :success
48 48 assert_template 'new'
49 49 assert_select 'input[name=?][type=hidden]', 'project_id'
50 50 assert_select 'input[name=?][type=hidden]', 'issue_id', 0
51 51 assert_select 'select[name=?]', 'time_entry[project_id]', 0
52 52 end
53 53
54 54 def test_new_with_issue_id
55 55 @request.session[:user_id] = 3
56 56 get :new, :issue_id => 2
57 57 assert_response :success
58 58 assert_template 'new'
59 59 assert_select 'input[name=?][type=hidden]', 'project_id', 0
60 60 assert_select 'input[name=?][type=hidden]', 'issue_id'
61 61 assert_select 'select[name=?]', 'time_entry[project_id]', 0
62 62 end
63 63
64 64 def test_new_without_project_should_prefill_the_form
65 65 @request.session[:user_id] = 3
66 66 get :new, :time_entry => {:project_id => '1'}
67 67 assert_response :success
68 68 assert_template 'new'
69 69 assert_select 'select[name=?]', 'time_entry[project_id]' do
70 70 assert_select 'option[value="1"][selected=selected]'
71 71 end
72 72 end
73 73
74 74 def test_new_without_project_should_deny_without_permission
75 75 Role.all.each {|role| role.remove_permission! :log_time}
76 76 @request.session[:user_id] = 3
77 77
78 78 get :new
79 79 assert_response 403
80 80 end
81 81
82 82 def test_new_should_select_default_activity
83 83 @request.session[:user_id] = 3
84 84 get :new, :project_id => 1
85 85 assert_response :success
86 86 assert_select 'select[name=?]', 'time_entry[activity_id]' do
87 87 assert_select 'option[selected=selected]', :text => 'Development'
88 88 end
89 89 end
90 90
91 91 def test_new_should_only_show_active_time_entry_activities
92 92 @request.session[:user_id] = 3
93 93 get :new, :project_id => 1
94 94 assert_response :success
95 95 assert_select 'option', :text => 'Inactive Activity', :count => 0
96 96 end
97 97
98 98 def test_post_new_as_js_should_update_activity_options
99 99 @request.session[:user_id] = 3
100 100 post :new, :time_entry => {:project_id => 1}, :format => 'js'
101 101 assert_response :success
102 102 assert_include '#time_entry_activity_id', response.body
103 103 end
104 104
105 105 def test_get_edit_existing_time
106 106 @request.session[:user_id] = 2
107 107 get :edit, :id => 2, :project_id => nil
108 108 assert_response :success
109 109 assert_template 'edit'
110 110 assert_select 'form[action=?]', '/time_entries/2'
111 111 end
112 112
113 113 def test_get_edit_with_an_existing_time_entry_with_inactive_activity
114 114 te = TimeEntry.find(1)
115 115 te.activity = TimeEntryActivity.find_by_name("Inactive Activity")
116 116 te.save!(:validate => false)
117 117
118 118 @request.session[:user_id] = 1
119 119 get :edit, :project_id => 1, :id => 1
120 120 assert_response :success
121 121 assert_template 'edit'
122 122 # Blank option since nothing is pre-selected
123 123 assert_select 'option', :text => '--- Please select ---'
124 124 end
125 125
126 126 def test_post_create
127 127 @request.session[:user_id] = 3
128 128 assert_difference 'TimeEntry.count' do
129 129 post :create, :project_id => 1,
130 130 :time_entry => {:comments => 'Some work on TimelogControllerTest',
131 131 # Not the default activity
132 132 :activity_id => '11',
133 133 :spent_on => '2008-03-14',
134 134 :issue_id => '1',
135 135 :hours => '7.3'}
136 136 assert_redirected_to '/projects/ecookbook/time_entries'
137 137 end
138 138
139 139 t = TimeEntry.order('id DESC').first
140 140 assert_not_nil t
141 141 assert_equal 'Some work on TimelogControllerTest', t.comments
142 142 assert_equal 1, t.project_id
143 143 assert_equal 1, t.issue_id
144 144 assert_equal 11, t.activity_id
145 145 assert_equal 7.3, t.hours
146 146 assert_equal 3, t.user_id
147 147 end
148 148
149 149 def test_post_create_with_blank_issue
150 150 @request.session[:user_id] = 3
151 151 assert_difference 'TimeEntry.count' do
152 152 post :create, :project_id => 1,
153 153 :time_entry => {:comments => 'Some work on TimelogControllerTest',
154 154 # Not the default activity
155 155 :activity_id => '11',
156 156 :issue_id => '',
157 157 :spent_on => '2008-03-14',
158 158 :hours => '7.3'}
159 159 assert_redirected_to '/projects/ecookbook/time_entries'
160 160 end
161 161
162 162 t = TimeEntry.order('id DESC').first
163 163 assert_not_nil t
164 164 assert_equal 'Some work on TimelogControllerTest', t.comments
165 165 assert_equal 1, t.project_id
166 166 assert_nil t.issue_id
167 167 assert_equal 11, t.activity_id
168 168 assert_equal 7.3, t.hours
169 169 assert_equal 3, t.user_id
170 170 end
171 171
172 172 def test_create_on_project_with_time_tracking_disabled_should_fail
173 173 Project.find(1).disable_module! :time_tracking
174 174
175 175 @request.session[:user_id] = 2
176 176 assert_no_difference 'TimeEntry.count' do
177 177 post :create, :time_entry => {
178 178 :project_id => '1', :issue_id => '',
179 179 :activity_id => '11', :spent_on => '2008-03-14', :hours => '7.3'
180 180 }
181 181 end
182 182 end
183 183
184 184 def test_create_on_project_without_permission_should_fail
185 185 Role.find(1).remove_permission! :log_time
186 186
187 187 @request.session[:user_id] = 2
188 188 assert_no_difference 'TimeEntry.count' do
189 189 post :create, :time_entry => {
190 190 :project_id => '1', :issue_id => '',
191 191 :activity_id => '11', :spent_on => '2008-03-14', :hours => '7.3'
192 192 }
193 193 end
194 194 end
195 195
196 196 def test_create_on_issue_in_project_with_time_tracking_disabled_should_fail
197 197 Project.find(1).disable_module! :time_tracking
198 198
199 199 @request.session[:user_id] = 2
200 200 assert_no_difference 'TimeEntry.count' do
201 201 post :create, :time_entry => {
202 202 :project_id => '', :issue_id => '1',
203 203 :activity_id => '11', :spent_on => '2008-03-14', :hours => '7.3'
204 204 }
205 205 assert_select_error /Issue is invalid/
206 206 end
207 207 end
208 208
209 209 def test_create_on_issue_in_project_without_permission_should_fail
210 210 Role.find(1).remove_permission! :log_time
211 211
212 212 @request.session[:user_id] = 2
213 213 assert_no_difference 'TimeEntry.count' do
214 214 post :create, :time_entry => {
215 215 :project_id => '', :issue_id => '1',
216 216 :activity_id => '11', :spent_on => '2008-03-14', :hours => '7.3'
217 217 }
218 218 assert_select_error /Issue is invalid/
219 219 end
220 220 end
221 221
222 222 def test_create_on_issue_that_is_not_visible_should_not_disclose_subject
223 223 issue = Issue.generate!(:subject => "issue_that_is_not_visible", :is_private => true)
224 224 assert !issue.visible?(User.find(3))
225 225
226 226 @request.session[:user_id] = 3
227 227 assert_no_difference 'TimeEntry.count' do
228 228 post :create, :time_entry => {
229 229 :project_id => '', :issue_id => issue.id.to_s,
230 230 :activity_id => '11', :spent_on => '2008-03-14', :hours => '7.3'
231 231 }
232 232 end
233 233 assert_select_error /Issue is invalid/
234 234 assert_select "input[name=?][value=?]", "time_entry[issue_id]", issue.id.to_s
235 235 assert_select "#time_entry_issue", 0
236 236 assert !response.body.include?('issue_that_is_not_visible')
237 237 end
238 238
239 239 def test_create_and_continue_at_project_level
240 240 @request.session[:user_id] = 2
241 241 assert_difference 'TimeEntry.count' do
242 242 post :create, :time_entry => {:project_id => '1',
243 243 :activity_id => '11',
244 244 :issue_id => '',
245 245 :spent_on => '2008-03-14',
246 246 :hours => '7.3'},
247 247 :continue => '1'
248 248 assert_redirected_to '/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=&time_entry%5Bproject_id%5D=1'
249 249 end
250 250 end
251 251
252 252 def test_create_and_continue_at_issue_level
253 253 @request.session[:user_id] = 2
254 254 assert_difference 'TimeEntry.count' do
255 255 post :create, :time_entry => {:project_id => '',
256 256 :activity_id => '11',
257 257 :issue_id => '1',
258 258 :spent_on => '2008-03-14',
259 259 :hours => '7.3'},
260 260 :continue => '1'
261 261 assert_redirected_to '/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=1&time_entry%5Bproject_id%5D='
262 262 end
263 263 end
264 264
265 265 def test_create_and_continue_with_project_id
266 266 @request.session[:user_id] = 2
267 267 assert_difference 'TimeEntry.count' do
268 268 post :create, :project_id => 1,
269 269 :time_entry => {:activity_id => '11',
270 270 :issue_id => '',
271 271 :spent_on => '2008-03-14',
272 272 :hours => '7.3'},
273 273 :continue => '1'
274 274 assert_redirected_to '/projects/ecookbook/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=&time_entry%5Bproject_id%5D='
275 275 end
276 276 end
277 277
278 278 def test_create_and_continue_with_issue_id
279 279 @request.session[:user_id] = 2
280 280 assert_difference 'TimeEntry.count' do
281 281 post :create, :issue_id => 1,
282 282 :time_entry => {:activity_id => '11',
283 283 :issue_id => '1',
284 284 :spent_on => '2008-03-14',
285 285 :hours => '7.3'},
286 286 :continue => '1'
287 287 assert_redirected_to '/issues/1/time_entries/new?time_entry%5Bactivity_id%5D=11&time_entry%5Bissue_id%5D=1&time_entry%5Bproject_id%5D='
288 288 end
289 289 end
290 290
291 291 def test_create_without_log_time_permission_should_be_denied
292 292 @request.session[:user_id] = 2
293 293 Role.find_by_name('Manager').remove_permission! :log_time
294 294 post :create, :project_id => 1,
295 295 :time_entry => {:activity_id => '11',
296 296 :issue_id => '',
297 297 :spent_on => '2008-03-14',
298 298 :hours => '7.3'}
299 299
300 300 assert_response 403
301 301 end
302 302
303 303 def test_create_without_project_and_issue_should_fail
304 304 @request.session[:user_id] = 2
305 305 post :create, :time_entry => {:issue_id => ''}
306 306
307 307 assert_response :success
308 308 assert_template 'new'
309 309 end
310 310
311 311 def test_create_with_failure
312 312 @request.session[:user_id] = 2
313 313 post :create, :project_id => 1,
314 314 :time_entry => {:activity_id => '',
315 315 :issue_id => '',
316 316 :spent_on => '2008-03-14',
317 317 :hours => '7.3'}
318 318
319 319 assert_response :success
320 320 assert_template 'new'
321 321 end
322 322
323 323 def test_create_without_project
324 324 @request.session[:user_id] = 2
325 325 assert_difference 'TimeEntry.count' do
326 326 post :create, :time_entry => {:project_id => '1',
327 327 :activity_id => '11',
328 328 :issue_id => '',
329 329 :spent_on => '2008-03-14',
330 330 :hours => '7.3'}
331 331 end
332 332
333 333 assert_redirected_to '/projects/ecookbook/time_entries'
334 334 time_entry = TimeEntry.order('id DESC').first
335 335 assert_equal 1, time_entry.project_id
336 336 end
337 337
338 338 def test_create_without_project_should_fail_with_issue_not_inside_project
339 339 @request.session[:user_id] = 2
340 340 assert_no_difference 'TimeEntry.count' do
341 341 post :create, :time_entry => {:project_id => '1',
342 342 :activity_id => '11',
343 343 :issue_id => '5',
344 344 :spent_on => '2008-03-14',
345 345 :hours => '7.3'}
346 346 end
347 347
348 348 assert_response :success
349 349 assert assigns(:time_entry).errors[:issue_id].present?
350 350 end
351 351
352 352 def test_create_without_project_should_deny_without_permission
353 353 @request.session[:user_id] = 2
354 354 Project.find(3).disable_module!(:time_tracking)
355 355
356 356 assert_no_difference 'TimeEntry.count' do
357 357 post :create, :time_entry => {:project_id => '3',
358 358 :activity_id => '11',
359 359 :issue_id => '',
360 360 :spent_on => '2008-03-14',
361 361 :hours => '7.3'}
362 362 end
363 363
364 364 assert_response 403
365 365 end
366 366
367 367 def test_create_without_project_with_failure
368 368 @request.session[:user_id] = 2
369 369 assert_no_difference 'TimeEntry.count' do
370 370 post :create, :time_entry => {:project_id => '1',
371 371 :activity_id => '11',
372 372 :issue_id => '',
373 373 :spent_on => '2008-03-14',
374 374 :hours => ''}
375 375 end
376 376
377 377 assert_response :success
378 378 assert_select 'select[name=?]', 'time_entry[project_id]' do
379 379 assert_select 'option[value="1"][selected=selected]'
380 380 end
381 381 end
382 382
383 383 def test_update
384 384 entry = TimeEntry.find(1)
385 385 assert_equal 1, entry.issue_id
386 386 assert_equal 2, entry.user_id
387 387
388 388 @request.session[:user_id] = 1
389 389 put :update, :id => 1,
390 390 :time_entry => {:issue_id => '2',
391 391 :hours => '8'}
392 392 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
393 393 entry.reload
394 394
395 395 assert_equal 8, entry.hours
396 396 assert_equal 2, entry.issue_id
397 397 assert_equal 2, entry.user_id
398 398 end
399 399
400 400 def test_update_should_allow_to_change_issue_to_another_project
401 401 entry = TimeEntry.generate!(:issue_id => 1)
402 402
403 403 @request.session[:user_id] = 1
404 404 put :update, :id => entry.id, :time_entry => {:issue_id => '5'}
405 405 assert_response 302
406 406 entry.reload
407 407
408 408 assert_equal 5, entry.issue_id
409 409 assert_equal 3, entry.project_id
410 410 end
411 411
412 412 def test_update_should_not_allow_to_change_issue_to_an_invalid_project
413 413 entry = TimeEntry.generate!(:issue_id => 1)
414 414 Project.find(3).disable_module!(:time_tracking)
415 415
416 416 @request.session[:user_id] = 1
417 417 put :update, :id => entry.id, :time_entry => {:issue_id => '5'}
418 418 assert_response 200
419 419 assert_include "Issue is invalid", assigns(:time_entry).errors.full_messages
420 420 end
421 421
422 422 def test_get_bulk_edit
423 423 @request.session[:user_id] = 2
424 424 get :bulk_edit, :ids => [1, 2]
425 425 assert_response :success
426 426 assert_template 'bulk_edit'
427 427
428 428 assert_select 'ul#bulk-selection' do
429 429 assert_select 'li', 2
430 430 assert_select 'li a', :text => '03/23/2007 - eCookbook: 4.25 hours'
431 431 end
432 432
433 433 assert_select 'form#bulk_edit_form[action=?]', '/time_entries/bulk_update' do
434 434 # System wide custom field
435 435 assert_select 'select[name=?]', 'time_entry[custom_field_values][10]'
436 436
437 437 # Activities
438 438 assert_select 'select[name=?]', 'time_entry[activity_id]' do
439 439 assert_select 'option[value=""]', :text => '(No change)'
440 440 assert_select 'option[value="9"]', :text => 'Design'
441 441 end
442 442 end
443 443 end
444 444
445 445 def test_get_bulk_edit_on_different_projects
446 446 @request.session[:user_id] = 2
447 447 get :bulk_edit, :ids => [1, 2, 6]
448 448 assert_response :success
449 449 assert_template 'bulk_edit'
450 450 end
451 451
452 452 def test_bulk_edit_with_edit_own_time_entries_permission
453 453 @request.session[:user_id] = 2
454 454 Role.find_by_name('Manager').remove_permission! :edit_time_entries
455 455 Role.find_by_name('Manager').add_permission! :edit_own_time_entries
456 456 ids = (0..1).map {TimeEntry.generate!(:user => User.find(2)).id}
457 457
458 458 get :bulk_edit, :ids => ids
459 459 assert_response :success
460 460 end
461 461
462 462 def test_bulk_update
463 463 @request.session[:user_id] = 2
464 464 # update time entry activity
465 465 post :bulk_update, :ids => [1, 2], :time_entry => { :activity_id => 9}
466 466
467 467 assert_response 302
468 468 # check that the issues were updated
469 469 assert_equal [9, 9], TimeEntry.where(:id => [1, 2]).collect {|i| i.activity_id}
470 470 end
471 471
472 472 def test_bulk_update_with_failure
473 473 @request.session[:user_id] = 2
474 474 post :bulk_update, :ids => [1, 2], :time_entry => { :hours => 'A'}
475 475
476 476 assert_response 302
477 477 assert_match /Failed to save 2 time entrie/, flash[:error]
478 478 end
479 479
480 480 def test_bulk_update_on_different_projects
481 481 @request.session[:user_id] = 2
482 482 # makes user a manager on the other project
483 483 Member.create!(:user_id => 2, :project_id => 3, :role_ids => [1])
484 484
485 485 # update time entry activity
486 486 post :bulk_update, :ids => [1, 2, 4], :time_entry => { :activity_id => 9 }
487 487
488 488 assert_response 302
489 489 # check that the issues were updated
490 490 assert_equal [9, 9, 9], TimeEntry.where(:id => [1, 2, 4]).collect {|i| i.activity_id}
491 491 end
492 492
493 493 def test_bulk_update_on_different_projects_without_rights
494 494 @request.session[:user_id] = 3
495 495 user = User.find(3)
496 496 action = { :controller => "timelog", :action => "bulk_update" }
497 497 assert user.allowed_to?(action, TimeEntry.find(1).project)
498 498 assert ! user.allowed_to?(action, TimeEntry.find(5).project)
499 499 post :bulk_update, :ids => [1, 5], :time_entry => { :activity_id => 9 }
500 500 assert_response 403
501 501 end
502 502
503 503 def test_bulk_update_with_edit_own_time_entries_permission
504 504 @request.session[:user_id] = 2
505 505 Role.find_by_name('Manager').remove_permission! :edit_time_entries
506 506 Role.find_by_name('Manager').add_permission! :edit_own_time_entries
507 507 ids = (0..1).map {TimeEntry.generate!(:user => User.find(2)).id}
508 508
509 509 post :bulk_update, :ids => ids, :time_entry => { :activity_id => 9 }
510 510 assert_response 302
511 511 end
512 512
513 513 def test_bulk_update_with_edit_own_time_entries_permissions_should_be_denied_for_time_entries_of_other_user
514 514 @request.session[:user_id] = 2
515 515 Role.find_by_name('Manager').remove_permission! :edit_time_entries
516 516 Role.find_by_name('Manager').add_permission! :edit_own_time_entries
517 517
518 518 post :bulk_update, :ids => [1, 2], :time_entry => { :activity_id => 9 }
519 519 assert_response 403
520 520 end
521 521
522 522 def test_bulk_update_custom_field
523 523 @request.session[:user_id] = 2
524 524 post :bulk_update, :ids => [1, 2], :time_entry => { :custom_field_values => {'10' => '0'} }
525 525
526 526 assert_response 302
527 527 assert_equal ["0", "0"], TimeEntry.where(:id => [1, 2]).collect {|i| i.custom_value_for(10).value}
528 528 end
529 529
530 530 def test_bulk_update_clear_custom_field
531 531 field = TimeEntryCustomField.generate!(:field_format => 'string')
532 532 @request.session[:user_id] = 2
533 533 post :bulk_update, :ids => [1, 2], :time_entry => { :custom_field_values => {field.id.to_s => '__none__'} }
534 534
535 535 assert_response 302
536 536 assert_equal ["", ""], TimeEntry.where(:id => [1, 2]).collect {|i| i.custom_value_for(field).value}
537 537 end
538 538
539 539 def test_post_bulk_update_should_redirect_back_using_the_back_url_parameter
540 540 @request.session[:user_id] = 2
541 541 post :bulk_update, :ids => [1,2], :back_url => '/time_entries'
542 542
543 543 assert_response :redirect
544 544 assert_redirected_to '/time_entries'
545 545 end
546 546
547 547 def test_post_bulk_update_should_not_redirect_back_using_the_back_url_parameter_off_the_host
548 548 @request.session[:user_id] = 2
549 549 post :bulk_update, :ids => [1,2], :back_url => 'http://google.com'
550 550
551 551 assert_response :redirect
552 552 assert_redirected_to :controller => 'timelog', :action => 'index', :project_id => Project.find(1).identifier
553 553 end
554 554
555 555 def test_post_bulk_update_without_edit_permission_should_be_denied
556 556 @request.session[:user_id] = 2
557 557 Role.find_by_name('Manager').remove_permission! :edit_time_entries
558 558 post :bulk_update, :ids => [1,2]
559 559
560 560 assert_response 403
561 561 end
562 562
563 563 def test_destroy
564 564 @request.session[:user_id] = 2
565 565 delete :destroy, :id => 1
566 566 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
567 567 assert_equal I18n.t(:notice_successful_delete), flash[:notice]
568 568 assert_nil TimeEntry.find_by_id(1)
569 569 end
570 570
571 571 def test_destroy_should_fail
572 572 # simulate that this fails (e.g. due to a plugin), see #5700
573 573 TimeEntry.any_instance.expects(:destroy).returns(false)
574 574
575 575 @request.session[:user_id] = 2
576 576 delete :destroy, :id => 1
577 577 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
578 578 assert_equal I18n.t(:notice_unable_delete_time_entry), flash[:error]
579 579 assert_not_nil TimeEntry.find_by_id(1)
580 580 end
581 581
582 582 def test_index_all_projects
583 583 get :index
584 584 assert_response :success
585 585 assert_template 'index'
586 586 assert_not_nil assigns(:total_hours)
587 587 assert_equal "162.90", "%.2f" % assigns(:total_hours)
588 588 assert_select 'form#query_form[action=?]', '/time_entries'
589 589 end
590 590
591 591 def test_index_all_projects_should_show_log_time_link
592 592 @request.session[:user_id] = 2
593 593 get :index
594 594 assert_response :success
595 595 assert_template 'index'
596 596 assert_select 'a[href=?]', '/time_entries/new', :text => /Log time/
597 597 end
598 598
599 599 def test_index_my_spent_time
600 600 @request.session[:user_id] = 2
601 601 get :index, :user_id => 'me'
602 602 assert_response :success
603 603 assert_template 'index'
604 604 assert assigns(:entries).all? {|entry| entry.user_id == 2}
605 605 end
606 606
607 607 def test_index_at_project_level
608 608 get :index, :project_id => 'ecookbook'
609 609 assert_response :success
610 610 assert_template 'index'
611 611 assert_not_nil assigns(:entries)
612 612 assert_equal 4, assigns(:entries).size
613 613 # project and subproject
614 614 assert_equal [1, 3], assigns(:entries).collect(&:project_id).uniq.sort
615 615 assert_not_nil assigns(:total_hours)
616 616 assert_equal "162.90", "%.2f" % assigns(:total_hours)
617 617 assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries'
618 618 end
619 619
620 620 def test_index_with_display_subprojects_issues_to_false_should_not_include_subproject_entries
621 621 entry = TimeEntry.generate!(:project => Project.find(3))
622 622
623 623 with_settings :display_subprojects_issues => '0' do
624 624 get :index, :project_id => 'ecookbook'
625 625 assert_response :success
626 626 assert_template 'index'
627 627 assert_not_include entry, assigns(:entries)
628 628 end
629 629 end
630 630
631 631 def test_index_with_display_subprojects_issues_to_false_and_subproject_filter_should_include_subproject_entries
632 632 entry = TimeEntry.generate!(:project => Project.find(3))
633 633
634 634 with_settings :display_subprojects_issues => '0' do
635 635 get :index, :project_id => 'ecookbook', :subproject_id => 3
636 636 assert_response :success
637 637 assert_template 'index'
638 638 assert_include entry, assigns(:entries)
639 639 end
640 640 end
641 641
642 def test_index_at_project_level_with_issue_id_short_filter
643 issue = Issue.generate!(:project_id => 1)
644 TimeEntry.generate!(:issue => issue, :hours => 4)
645 TimeEntry.generate!(:issue => issue, :hours => 3)
646 @request.session[:user_id] = 2
647
648 get :index, :project_id => 'ecookbook', :issue_id => issue.id.to_s, :set_filter => 1
649 assert_select '.total-hours', :text => 'Total time: 7.00 hours'
650 end
651
652 def test_index_at_project_level_with_issue_fixed_version_id_short_filter
653 version = Version.generate!(:project_id => 1)
654 issue = Issue.generate!(:project_id => 1, :fixed_version => version)
655 TimeEntry.generate!(:issue => issue, :hours => 2)
656 TimeEntry.generate!(:issue => issue, :hours => 3)
657 @request.session[:user_id] = 2
658
659 get :index, :project_id => 'ecookbook', :"issue.fixed_version_id" => version.id.to_s, :set_filter => 1
660 assert_select '.total-hours', :text => 'Total time: 5.00 hours'
661 end
662
642 663 def test_index_at_project_level_with_date_range
643 664 get :index, :project_id => 'ecookbook',
644 665 :f => ['spent_on'],
645 666 :op => {'spent_on' => '><'},
646 667 :v => {'spent_on' => ['2007-03-20', '2007-04-30']}
647 668 assert_response :success
648 669 assert_template 'index'
649 670 assert_not_nil assigns(:entries)
650 671 assert_equal 3, assigns(:entries).size
651 672 assert_not_nil assigns(:total_hours)
652 673 assert_equal "12.90", "%.2f" % assigns(:total_hours)
653 674 assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries'
654 675 end
655 676
656 677 def test_index_at_project_level_with_date_range_using_from_and_to_params
657 678 get :index, :project_id => 'ecookbook', :from => '2007-03-20', :to => '2007-04-30'
658 679 assert_response :success
659 680 assert_template 'index'
660 681 assert_not_nil assigns(:entries)
661 682 assert_equal 3, assigns(:entries).size
662 683 assert_not_nil assigns(:total_hours)
663 684 assert_equal "12.90", "%.2f" % assigns(:total_hours)
664 685 assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries'
665 686 end
666 687
667 688 def test_index_at_project_level_with_period
668 689 get :index, :project_id => 'ecookbook',
669 690 :f => ['spent_on'],
670 691 :op => {'spent_on' => '>t-'},
671 692 :v => {'spent_on' => ['7']}
672 693 assert_response :success
673 694 assert_template 'index'
674 695 assert_not_nil assigns(:entries)
675 696 assert_not_nil assigns(:total_hours)
676 697 assert_select 'form#query_form[action=?]', '/projects/ecookbook/time_entries'
677 698 end
678 699
679 700 def test_index_should_sort_by_spent_on_and_created_on
680 701 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)
681 702 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)
682 703 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)
683 704
684 705 get :index, :project_id => 1,
685 706 :f => ['spent_on'],
686 707 :op => {'spent_on' => '><'},
687 708 :v => {'spent_on' => ['2012-06-15', '2012-06-16']}
688 709 assert_response :success
689 710 assert_equal [t2, t1, t3], assigns(:entries)
690 711
691 712 get :index, :project_id => 1,
692 713 :f => ['spent_on'],
693 714 :op => {'spent_on' => '><'},
694 715 :v => {'spent_on' => ['2012-06-15', '2012-06-16']},
695 716 :sort => 'spent_on'
696 717 assert_response :success
697 718 assert_equal [t3, t1, t2], assigns(:entries)
698 719 end
699 720
700 721 def test_index_with_filter_on_issue_custom_field
701 722 issue = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {2 => 'filter_on_issue_custom_field'})
702 723 entry = TimeEntry.generate!(:issue => issue, :hours => 2.5)
703 724
704 725 get :index, :f => ['issue.cf_2'], :op => {'issue.cf_2' => '='}, :v => {'issue.cf_2' => ['filter_on_issue_custom_field']}
705 726 assert_response :success
706 727 assert_equal [entry], assigns(:entries)
707 728 end
708 729
709 730 def test_index_with_issue_custom_field_column
710 731 issue = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {2 => 'filter_on_issue_custom_field'})
711 732 entry = TimeEntry.generate!(:issue => issue, :hours => 2.5)
712 733
713 734 get :index, :c => %w(project spent_on issue comments hours issue.cf_2)
714 735 assert_response :success
715 736 assert_include :'issue.cf_2', assigns(:query).column_names
716 737 assert_select 'td.issue_cf_2', :text => 'filter_on_issue_custom_field'
717 738 end
718 739
719 740 def test_index_with_time_entry_custom_field_column
720 741 field = TimeEntryCustomField.generate!(:field_format => 'string')
721 742 entry = TimeEntry.generate!(:hours => 2.5, :custom_field_values => {field.id => 'CF Value'})
722 743 field_name = "cf_#{field.id}"
723 744
724 745 get :index, :c => ["hours", field_name]
725 746 assert_response :success
726 747 assert_include field_name.to_sym, assigns(:query).column_names
727 748 assert_select "td.#{field_name}", :text => 'CF Value'
728 749 end
729 750
730 751 def test_index_with_time_entry_custom_field_sorting
731 752 field = TimeEntryCustomField.generate!(:field_format => 'string', :name => 'String Field')
732 753 TimeEntry.generate!(:hours => 2.5, :custom_field_values => {field.id => 'CF Value 1'})
733 754 TimeEntry.generate!(:hours => 2.5, :custom_field_values => {field.id => 'CF Value 3'})
734 755 TimeEntry.generate!(:hours => 2.5, :custom_field_values => {field.id => 'CF Value 2'})
735 756 field_name = "cf_#{field.id}"
736 757
737 758 get :index, :c => ["hours", field_name], :sort => field_name
738 759 assert_response :success
739 760 assert_include field_name.to_sym, assigns(:query).column_names
740 761 assert_select "th a.sort", :text => 'String Field'
741 762
742 763 # Make sure that values are properly sorted
743 764 values = assigns(:entries).map {|e| e.custom_field_value(field)}.compact
744 765 assert_equal 3, values.size
745 766 assert_equal values.sort, values
746 767 end
747 768
748 769 def test_index_with_query
749 770 query = TimeEntryQuery.new(:project_id => 1, :name => 'Time Entry Query', :visibility => 2)
750 771 query.save!
751 772 @request.session[:user_id] = 2
752 773
753 774 get :index, :project_id => 'ecookbook', :query_id => query.id
754 775 assert_response :success
755 776 assert_select 'h2', :text => query.name
756 777 assert_select '#sidebar a.selected', :text => query.name
757 778 end
758 779
759 780 def test_index_atom_feed
760 781 get :index, :project_id => 1, :format => 'atom'
761 782 assert_response :success
762 783 assert_equal 'application/atom+xml', @response.content_type
763 784 assert_not_nil assigns(:items)
764 785 assert assigns(:items).first.is_a?(TimeEntry)
765 786 end
766 787
767 788 def test_index_at_project_level_should_include_csv_export_dialog
768 789 get :index, :project_id => 'ecookbook',
769 790 :f => ['spent_on'],
770 791 :op => {'spent_on' => '>='},
771 792 :v => {'spent_on' => ['2007-04-01']},
772 793 :c => ['spent_on', 'user']
773 794 assert_response :success
774 795
775 796 assert_select '#csv-export-options' do
776 797 assert_select 'form[action=?][method=get]', '/projects/ecookbook/time_entries.csv' do
777 798 # filter
778 799 assert_select 'input[name=?][value=?]', 'f[]', 'spent_on'
779 800 assert_select 'input[name=?][value=?]', 'op[spent_on]', '>='
780 801 assert_select 'input[name=?][value=?]', 'v[spent_on][]', '2007-04-01'
781 802 # columns
782 803 assert_select 'input[name=?][value=?]', 'c[]', 'spent_on'
783 804 assert_select 'input[name=?][value=?]', 'c[]', 'user'
784 805 assert_select 'input[name=?]', 'c[]', 2
785 806 end
786 807 end
787 808 end
788 809
789 810 def test_index_cross_project_should_include_csv_export_dialog
790 811 get :index
791 812 assert_response :success
792 813
793 814 assert_select '#csv-export-options' do
794 815 assert_select 'form[action=?][method=get]', '/time_entries.csv'
795 816 end
796 817 end
797 818
798 819 def test_index_csv_all_projects
799 820 with_settings :date_format => '%m/%d/%Y' do
800 821 get :index, :format => 'csv'
801 822 assert_response :success
802 823 assert_equal 'text/csv; header=present', response.content_type
803 824 end
804 825 end
805 826
806 827 def test_index_csv
807 828 with_settings :date_format => '%m/%d/%Y' do
808 829 get :index, :project_id => 1, :format => 'csv'
809 830 assert_response :success
810 831 assert_equal 'text/csv; header=present', response.content_type
811 832 end
812 833 end
813 834
814 835 def test_index_csv_should_fill_issue_column_with_tracker_id_and_subject
815 836 issue = Issue.find(1)
816 837 entry = TimeEntry.generate!(:issue => issue, :comments => "Issue column content test")
817 838
818 839 get :index, :format => 'csv'
819 840 line = response.body.split("\n").detect {|l| l.include?(entry.comments)}
820 841 assert_not_nil line
821 842 assert_include "#{issue.tracker} #1: #{issue.subject}", line
822 843 end
823 844 end
General Comments 0
You need to be logged in to leave comments. Login now