##// END OF EJS Templates
Allow filtering with timestamp (#8842)....
Jean-Philippe Lang -
r12202:a4d3da988a3e
parent child
Show More
@@ -1,853 +1,868
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}".to_sym
32 32 @frozen = options[:frozen]
33 33 end
34 34
35 35 def caption
36 36 @caption_key.is_a?(Symbol) ? l(@caption_key) : @caption_key
37 37 end
38 38
39 39 # Returns true if the column is sortable, otherwise false
40 40 def sortable?
41 41 !@sortable.nil?
42 42 end
43 43
44 44 def sortable
45 45 @sortable.is_a?(Proc) ? @sortable.call : @sortable
46 46 end
47 47
48 48 def inline?
49 49 @inline
50 50 end
51 51
52 52 def frozen?
53 53 @frozen
54 54 end
55 55
56 56 def value(object)
57 57 object.send name
58 58 end
59 59
60 60 def css_classes
61 61 name
62 62 end
63 63 end
64 64
65 65 class QueryCustomFieldColumn < QueryColumn
66 66
67 67 def initialize(custom_field)
68 68 self.name = "cf_#{custom_field.id}".to_sym
69 69 self.sortable = custom_field.order_statement || false
70 70 self.groupable = custom_field.group_statement || false
71 71 @inline = true
72 72 @cf = custom_field
73 73 end
74 74
75 75 def caption
76 76 @cf.name
77 77 end
78 78
79 79 def custom_field
80 80 @cf
81 81 end
82 82
83 83 def value(object)
84 84 if custom_field.visible_by?(object.project, User.current)
85 85 cv = object.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
86 86 cv.size > 1 ? cv.sort {|a,b| a.to_s <=> b.to_s} : cv.first
87 87 else
88 88 nil
89 89 end
90 90 end
91 91
92 92 def css_classes
93 93 @css_classes ||= "#{name} #{@cf.field_format}"
94 94 end
95 95 end
96 96
97 97 class QueryAssociationCustomFieldColumn < QueryCustomFieldColumn
98 98
99 99 def initialize(association, custom_field)
100 100 super(custom_field)
101 101 self.name = "#{association}.cf_#{custom_field.id}".to_sym
102 102 # TODO: support sorting/grouping by association custom field
103 103 self.sortable = false
104 104 self.groupable = false
105 105 @association = association
106 106 end
107 107
108 108 def value(object)
109 109 if assoc = object.send(@association)
110 110 super(assoc)
111 111 end
112 112 end
113 113
114 114 def css_classes
115 115 @css_classes ||= "#{@association}_cf_#{@cf.id} #{@cf.field_format}"
116 116 end
117 117 end
118 118
119 119 class Query < ActiveRecord::Base
120 120 class StatementInvalid < ::ActiveRecord::StatementInvalid
121 121 end
122 122
123 123 VISIBILITY_PRIVATE = 0
124 124 VISIBILITY_ROLES = 1
125 125 VISIBILITY_PUBLIC = 2
126 126
127 127 belongs_to :project
128 128 belongs_to :user
129 129 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}queries_roles#{table_name_suffix}", :foreign_key => "query_id"
130 130 serialize :filters
131 131 serialize :column_names
132 132 serialize :sort_criteria, Array
133 133 serialize :options, Hash
134 134
135 135 attr_protected :project_id, :user_id
136 136
137 137 validates_presence_of :name
138 138 validates_length_of :name, :maximum => 255
139 139 validates :visibility, :inclusion => { :in => [VISIBILITY_PUBLIC, VISIBILITY_ROLES, VISIBILITY_PRIVATE] }
140 140 validate :validate_query_filters
141 141 validate do |query|
142 142 errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) if query.visibility == VISIBILITY_ROLES && roles.blank?
143 143 end
144 144
145 145 after_save do |query|
146 146 if query.visibility_changed? && query.visibility != VISIBILITY_ROLES
147 147 query.roles.clear
148 148 end
149 149 end
150 150
151 151 class_attribute :operators
152 152 self.operators = {
153 153 "=" => :label_equals,
154 154 "!" => :label_not_equals,
155 155 "o" => :label_open_issues,
156 156 "c" => :label_closed_issues,
157 157 "!*" => :label_none,
158 158 "*" => :label_any,
159 159 ">=" => :label_greater_or_equal,
160 160 "<=" => :label_less_or_equal,
161 161 "><" => :label_between,
162 162 "<t+" => :label_in_less_than,
163 163 ">t+" => :label_in_more_than,
164 164 "><t+"=> :label_in_the_next_days,
165 165 "t+" => :label_in,
166 166 "t" => :label_today,
167 167 "ld" => :label_yesterday,
168 168 "w" => :label_this_week,
169 169 "lw" => :label_last_week,
170 170 "l2w" => [:label_last_n_weeks, {:count => 2}],
171 171 "m" => :label_this_month,
172 172 "lm" => :label_last_month,
173 173 "y" => :label_this_year,
174 174 ">t-" => :label_less_than_ago,
175 175 "<t-" => :label_more_than_ago,
176 176 "><t-"=> :label_in_the_past_days,
177 177 "t-" => :label_ago,
178 178 "~" => :label_contains,
179 179 "!~" => :label_not_contains,
180 180 "=p" => :label_any_issues_in_project,
181 181 "=!p" => :label_any_issues_not_in_project,
182 182 "!p" => :label_no_issues_in_project
183 183 }
184 184
185 185 class_attribute :operators_by_filter_type
186 186 self.operators_by_filter_type = {
187 187 :list => [ "=", "!" ],
188 188 :list_status => [ "o", "=", "!", "c", "*" ],
189 189 :list_optional => [ "=", "!", "!*", "*" ],
190 190 :list_subprojects => [ "*", "!*", "=" ],
191 191 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
192 192 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "!*", "*" ],
193 193 :string => [ "=", "~", "!", "!~", "!*", "*" ],
194 194 :text => [ "~", "!~", "!*", "*" ],
195 195 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
196 196 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
197 197 :relation => ["=", "=p", "=!p", "!p", "!*", "*"]
198 198 }
199 199
200 200 class_attribute :available_columns
201 201 self.available_columns = []
202 202
203 203 class_attribute :queried_class
204 204
205 205 def queried_table_name
206 206 @queried_table_name ||= self.class.queried_class.table_name
207 207 end
208 208
209 209 def initialize(attributes=nil, *args)
210 210 super attributes
211 211 @is_for_all = project.nil?
212 212 end
213 213
214 214 # Builds the query from the given params
215 215 def build_from_params(params)
216 216 if params[:fields] || params[:f]
217 217 self.filters = {}
218 218 add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v])
219 219 else
220 220 available_filters.keys.each do |field|
221 221 add_short_filter(field, params[field]) if params[field]
222 222 end
223 223 end
224 224 self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
225 225 self.column_names = params[:c] || (params[:query] && params[:query][:column_names])
226 226 self
227 227 end
228 228
229 229 # Builds a new query from the given params and attributes
230 230 def self.build_from_params(params, attributes={})
231 231 new(attributes).build_from_params(params)
232 232 end
233 233
234 234 def validate_query_filters
235 235 filters.each_key do |field|
236 236 if values_for(field)
237 237 case type_for(field)
238 238 when :integer
239 239 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+$/) }
240 240 when :float
241 241 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+(\.\d*)?$/) }
242 242 when :date, :date_past
243 243 case operator_for(field)
244 244 when "=", ">=", "<=", "><"
245 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?) }
245 add_filter_error(field, :invalid) if values_for(field).detect {|v|
246 v.present? && (!v.match(/\A\d{4}-\d{2}-\d{2}(T\d{2}((:)?\d{2}){,2}(Z|\d{2}:?\d{2})?)?\z/) || parse_date(v).nil?)
247 }
246 248 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
247 249 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
248 250 end
249 251 end
250 252 end
251 253
252 254 add_filter_error(field, :blank) unless
253 255 # filter requires one or more values
254 256 (values_for(field) and !values_for(field).first.blank?) or
255 257 # filter doesn't require any value
256 258 ["o", "c", "!*", "*", "t", "ld", "w", "lw", "l2w", "m", "lm", "y"].include? operator_for(field)
257 259 end if filters
258 260 end
259 261
260 262 def add_filter_error(field, message)
261 263 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
262 264 errors.add(:base, m)
263 265 end
264 266
265 267 def editable_by?(user)
266 268 return false unless user
267 269 # Admin can edit them all and regular users can edit their private queries
268 270 return true if user.admin? || (is_private? && self.user_id == user.id)
269 271 # Members can not edit public queries that are for all project (only admin is allowed to)
270 272 is_public? && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
271 273 end
272 274
273 275 def trackers
274 276 @trackers ||= project.nil? ? Tracker.sorted.all : project.rolled_up_trackers
275 277 end
276 278
277 279 # Returns a hash of localized labels for all filter operators
278 280 def self.operators_labels
279 281 operators.inject({}) {|h, operator| h[operator.first] = l(*operator.last); h}
280 282 end
281 283
282 284 # Returns a representation of the available filters for JSON serialization
283 285 def available_filters_as_json
284 286 json = {}
285 287 available_filters.each do |field, options|
286 288 json[field] = options.slice(:type, :name, :values).stringify_keys
287 289 end
288 290 json
289 291 end
290 292
291 293 def all_projects
292 294 @all_projects ||= Project.visible.all
293 295 end
294 296
295 297 def all_projects_values
296 298 return @all_projects_values if @all_projects_values
297 299
298 300 values = []
299 301 Project.project_tree(all_projects) do |p, level|
300 302 prefix = (level > 0 ? ('--' * level + ' ') : '')
301 303 values << ["#{prefix}#{p.name}", p.id.to_s]
302 304 end
303 305 @all_projects_values = values
304 306 end
305 307
306 308 # Adds available filters
307 309 def initialize_available_filters
308 310 # implemented by sub-classes
309 311 end
310 312 protected :initialize_available_filters
311 313
312 314 # Adds an available filter
313 315 def add_available_filter(field, options)
314 316 @available_filters ||= ActiveSupport::OrderedHash.new
315 317 @available_filters[field] = options
316 318 @available_filters
317 319 end
318 320
319 321 # Removes an available filter
320 322 def delete_available_filter(field)
321 323 if @available_filters
322 324 @available_filters.delete(field)
323 325 end
324 326 end
325 327
326 328 # Return a hash of available filters
327 329 def available_filters
328 330 unless @available_filters
329 331 initialize_available_filters
330 332 @available_filters.each do |field, options|
331 333 options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
332 334 end
333 335 end
334 336 @available_filters
335 337 end
336 338
337 339 def add_filter(field, operator, values=nil)
338 340 # values must be an array
339 341 return unless values.nil? || values.is_a?(Array)
340 342 # check if field is defined as an available filter
341 343 if available_filters.has_key? field
342 344 filter_options = available_filters[field]
343 345 filters[field] = {:operator => operator, :values => (values || [''])}
344 346 end
345 347 end
346 348
347 349 def add_short_filter(field, expression)
348 350 return unless expression && available_filters.has_key?(field)
349 351 field_type = available_filters[field][:type]
350 352 operators_by_filter_type[field_type].sort.reverse.detect do |operator|
351 353 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
352 354 values = $1
353 355 add_filter field, operator, values.present? ? values.split('|') : ['']
354 356 end || add_filter(field, '=', expression.split('|'))
355 357 end
356 358
357 359 # Add multiple filters using +add_filter+
358 360 def add_filters(fields, operators, values)
359 361 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
360 362 fields.each do |field|
361 363 add_filter(field, operators[field], values && values[field])
362 364 end
363 365 end
364 366 end
365 367
366 368 def has_filter?(field)
367 369 filters and filters[field]
368 370 end
369 371
370 372 def type_for(field)
371 373 available_filters[field][:type] if available_filters.has_key?(field)
372 374 end
373 375
374 376 def operator_for(field)
375 377 has_filter?(field) ? filters[field][:operator] : nil
376 378 end
377 379
378 380 def values_for(field)
379 381 has_filter?(field) ? filters[field][:values] : nil
380 382 end
381 383
382 384 def value_for(field, index=0)
383 385 (values_for(field) || [])[index]
384 386 end
385 387
386 388 def label_for(field)
387 389 label = available_filters[field][:name] if available_filters.has_key?(field)
388 390 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
389 391 end
390 392
391 393 def self.add_available_column(column)
392 394 self.available_columns << (column) if column.is_a?(QueryColumn)
393 395 end
394 396
395 397 # Returns an array of columns that can be used to group the results
396 398 def groupable_columns
397 399 available_columns.select {|c| c.groupable}
398 400 end
399 401
400 402 # Returns a Hash of columns and the key for sorting
401 403 def sortable_columns
402 404 available_columns.inject({}) {|h, column|
403 405 h[column.name.to_s] = column.sortable
404 406 h
405 407 }
406 408 end
407 409
408 410 def columns
409 411 # preserve the column_names order
410 412 cols = (has_default_columns? ? default_columns_names : column_names).collect do |name|
411 413 available_columns.find { |col| col.name == name }
412 414 end.compact
413 415 available_columns.select(&:frozen?) | cols
414 416 end
415 417
416 418 def inline_columns
417 419 columns.select(&:inline?)
418 420 end
419 421
420 422 def block_columns
421 423 columns.reject(&:inline?)
422 424 end
423 425
424 426 def available_inline_columns
425 427 available_columns.select(&:inline?)
426 428 end
427 429
428 430 def available_block_columns
429 431 available_columns.reject(&:inline?)
430 432 end
431 433
432 434 def default_columns_names
433 435 []
434 436 end
435 437
436 438 def column_names=(names)
437 439 if names
438 440 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
439 441 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
440 442 # Set column_names to nil if default columns
441 443 if names == default_columns_names
442 444 names = nil
443 445 end
444 446 end
445 447 write_attribute(:column_names, names)
446 448 end
447 449
448 450 def has_column?(column)
449 451 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
450 452 end
451 453
452 454 def has_custom_field_column?
453 455 columns.any? {|column| column.is_a? QueryCustomFieldColumn}
454 456 end
455 457
456 458 def has_default_columns?
457 459 column_names.nil? || column_names.empty?
458 460 end
459 461
460 462 def sort_criteria=(arg)
461 463 c = []
462 464 if arg.is_a?(Hash)
463 465 arg = arg.keys.sort.collect {|k| arg[k]}
464 466 end
465 467 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
466 468 write_attribute(:sort_criteria, c)
467 469 end
468 470
469 471 def sort_criteria
470 472 read_attribute(:sort_criteria) || []
471 473 end
472 474
473 475 def sort_criteria_key(arg)
474 476 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
475 477 end
476 478
477 479 def sort_criteria_order(arg)
478 480 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
479 481 end
480 482
481 483 def sort_criteria_order_for(key)
482 484 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
483 485 end
484 486
485 487 # Returns the SQL sort order that should be prepended for grouping
486 488 def group_by_sort_order
487 489 if grouped? && (column = group_by_column)
488 490 order = sort_criteria_order_for(column.name) || column.default_order
489 491 column.sortable.is_a?(Array) ?
490 492 column.sortable.collect {|s| "#{s} #{order}"}.join(',') :
491 493 "#{column.sortable} #{order}"
492 494 end
493 495 end
494 496
495 497 # Returns true if the query is a grouped query
496 498 def grouped?
497 499 !group_by_column.nil?
498 500 end
499 501
500 502 def group_by_column
501 503 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
502 504 end
503 505
504 506 def group_by_statement
505 507 group_by_column.try(:groupable)
506 508 end
507 509
508 510 def project_statement
509 511 project_clauses = []
510 512 if project && !project.descendants.active.empty?
511 513 ids = [project.id]
512 514 if has_filter?("subproject_id")
513 515 case operator_for("subproject_id")
514 516 when '='
515 517 # include the selected subprojects
516 518 ids += values_for("subproject_id").each(&:to_i)
517 519 when '!*'
518 520 # main project only
519 521 else
520 522 # all subprojects
521 523 ids += project.descendants.collect(&:id)
522 524 end
523 525 elsif Setting.display_subprojects_issues?
524 526 ids += project.descendants.collect(&:id)
525 527 end
526 528 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
527 529 elsif project
528 530 project_clauses << "#{Project.table_name}.id = %d" % project.id
529 531 end
530 532 project_clauses.any? ? project_clauses.join(' AND ') : nil
531 533 end
532 534
533 535 def statement
534 536 # filters clauses
535 537 filters_clauses = []
536 538 filters.each_key do |field|
537 539 next if field == "subproject_id"
538 540 v = values_for(field).clone
539 541 next unless v and !v.empty?
540 542 operator = operator_for(field)
541 543
542 544 # "me" value subsitution
543 545 if %w(assigned_to_id author_id user_id watcher_id).include?(field)
544 546 if v.delete("me")
545 547 if User.current.logged?
546 548 v.push(User.current.id.to_s)
547 549 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
548 550 else
549 551 v.push("0")
550 552 end
551 553 end
552 554 end
553 555
554 556 if field == 'project_id'
555 557 if v.delete('mine')
556 558 v += User.current.memberships.map(&:project_id).map(&:to_s)
557 559 end
558 560 end
559 561
560 562 if field =~ /cf_(\d+)$/
561 563 # custom field
562 564 filters_clauses << sql_for_custom_field(field, operator, v, $1)
563 565 elsif respond_to?("sql_for_#{field}_field")
564 566 # specific statement
565 567 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
566 568 else
567 569 # regular field
568 570 filters_clauses << '(' + sql_for_field(field, operator, v, queried_table_name, field) + ')'
569 571 end
570 572 end if filters and valid?
571 573
572 574 if (c = group_by_column) && c.is_a?(QueryCustomFieldColumn)
573 575 # Excludes results for which the grouped custom field is not visible
574 576 filters_clauses << c.custom_field.visibility_by_project_condition
575 577 end
576 578
577 579 filters_clauses << project_statement
578 580 filters_clauses.reject!(&:blank?)
579 581
580 582 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
581 583 end
582 584
583 585 private
584 586
585 587 def sql_for_custom_field(field, operator, value, custom_field_id)
586 588 db_table = CustomValue.table_name
587 589 db_field = 'value'
588 590 filter = @available_filters[field]
589 591 return nil unless filter
590 592 if filter[:field].format.target_class && filter[:field].format.target_class <= User
591 593 if value.delete('me')
592 594 value.push User.current.id.to_s
593 595 end
594 596 end
595 597 not_in = nil
596 598 if operator == '!'
597 599 # Makes ! operator work for custom fields with multiple values
598 600 operator = '='
599 601 not_in = 'NOT'
600 602 end
601 603 customized_key = "id"
602 604 customized_class = queried_class
603 605 if field =~ /^(.+)\.cf_/
604 606 assoc = $1
605 607 customized_key = "#{assoc}_id"
606 608 customized_class = queried_class.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
607 609 raise "Unknown #{queried_class.name} association #{assoc}" unless customized_class
608 610 end
609 611 where = sql_for_field(field, operator, value, db_table, db_field, true)
610 612 if operator =~ /[<>]/
611 613 where = "(#{where}) AND #{db_table}.#{db_field} <> ''"
612 614 end
613 615 "#{queried_table_name}.#{customized_key} #{not_in} IN (" +
614 616 "SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name}" +
615 617 " 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}" +
616 618 " WHERE (#{where}) AND (#{filter[:field].visibility_by_project_condition}))"
617 619 end
618 620
619 621 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
620 622 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
621 623 sql = ''
622 624 case operator
623 625 when "="
624 626 if value.any?
625 627 case type_for(field)
626 628 when :date, :date_past
627 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
629 sql = date_clause(db_table, db_field, parse_date(value.first), parse_date(value.first))
628 630 when :integer
629 631 if is_custom_filter
630 632 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})"
631 633 else
632 634 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
633 635 end
634 636 when :float
635 637 if is_custom_filter
636 638 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})"
637 639 else
638 640 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
639 641 end
640 642 else
641 643 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
642 644 end
643 645 else
644 646 # IN an empty set
645 647 sql = "1=0"
646 648 end
647 649 when "!"
648 650 if value.any?
649 651 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
650 652 else
651 653 # NOT IN an empty set
652 654 sql = "1=1"
653 655 end
654 656 when "!*"
655 657 sql = "#{db_table}.#{db_field} IS NULL"
656 658 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
657 659 when "*"
658 660 sql = "#{db_table}.#{db_field} IS NOT NULL"
659 661 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
660 662 when ">="
661 663 if [:date, :date_past].include?(type_for(field))
662 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
664 sql = date_clause(db_table, db_field, parse_date(value.first), nil)
663 665 else
664 666 if is_custom_filter
665 667 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})"
666 668 else
667 669 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
668 670 end
669 671 end
670 672 when "<="
671 673 if [:date, :date_past].include?(type_for(field))
672 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
674 sql = date_clause(db_table, db_field, nil, parse_date(value.first))
673 675 else
674 676 if is_custom_filter
675 677 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})"
676 678 else
677 679 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
678 680 end
679 681 end
680 682 when "><"
681 683 if [:date, :date_past].include?(type_for(field))
682 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
684 sql = date_clause(db_table, db_field, parse_date(value[0]), parse_date(value[1]))
683 685 else
684 686 if is_custom_filter
685 687 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})"
686 688 else
687 689 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
688 690 end
689 691 end
690 692 when "o"
691 693 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
692 694 when "c"
693 695 sql = "#{queried_table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
694 696 when "><t-"
695 697 # between today - n days and today
696 698 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
697 699 when ">t-"
698 700 # >= today - n days
699 701 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil)
700 702 when "<t-"
701 703 # <= today - n days
702 704 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
703 705 when "t-"
704 706 # = n days in past
705 707 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
706 708 when "><t+"
707 709 # between today and today + n days
708 710 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
709 711 when ">t+"
710 712 # >= today + n days
711 713 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
712 714 when "<t+"
713 715 # <= today + n days
714 716 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i)
715 717 when "t+"
716 718 # = today + n days
717 719 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
718 720 when "t"
719 721 # = today
720 722 sql = relative_date_clause(db_table, db_field, 0, 0)
721 723 when "ld"
722 724 # = yesterday
723 725 sql = relative_date_clause(db_table, db_field, -1, -1)
724 726 when "w"
725 727 # = this week
726 728 first_day_of_week = l(:general_first_day_of_week).to_i
727 729 day_of_week = Date.today.cwday
728 730 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
729 731 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
730 732 when "lw"
731 733 # = last week
732 734 first_day_of_week = l(:general_first_day_of_week).to_i
733 735 day_of_week = Date.today.cwday
734 736 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
735 737 sql = relative_date_clause(db_table, db_field, - days_ago - 7, - days_ago - 1)
736 738 when "l2w"
737 739 # = last 2 weeks
738 740 first_day_of_week = l(:general_first_day_of_week).to_i
739 741 day_of_week = Date.today.cwday
740 742 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
741 743 sql = relative_date_clause(db_table, db_field, - days_ago - 14, - days_ago - 1)
742 744 when "m"
743 745 # = this month
744 746 date = Date.today
745 747 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month)
746 748 when "lm"
747 749 # = last month
748 750 date = Date.today.prev_month
749 751 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month)
750 752 when "y"
751 753 # = this year
752 754 date = Date.today
753 755 sql = date_clause(db_table, db_field, date.beginning_of_year, date.end_of_year)
754 756 when "~"
755 757 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
756 758 when "!~"
757 759 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
758 760 else
759 761 raise "Unknown query operator #{operator}"
760 762 end
761 763
762 764 return sql
763 765 end
764 766
765 767 # Adds a filter for the given custom field
766 768 def add_custom_field_filter(field, assoc=nil)
767 769 options = field.format.query_filter_options(field, self)
768 770 if field.format.target_class && field.format.target_class <= User
769 771 if options[:values].is_a?(Array) && User.current.logged?
770 772 options[:values].unshift ["<< #{l(:label_me)} >>", "me"]
771 773 end
772 774 end
773 775
774 776 filter_id = "cf_#{field.id}"
775 777 filter_name = field.name
776 778 if assoc.present?
777 779 filter_id = "#{assoc}.#{filter_id}"
778 780 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
779 781 end
780 782 add_available_filter filter_id, options.merge({
781 783 :name => filter_name,
782 784 :field => field
783 785 })
784 786 end
785 787
786 788 # Adds filters for the given custom fields scope
787 789 def add_custom_fields_filters(scope, assoc=nil)
788 790 scope.visible.where(:is_filter => true).sorted.each do |field|
789 791 add_custom_field_filter(field, assoc)
790 792 end
791 793 end
792 794
793 795 # Adds filters for the given associations custom fields
794 796 def add_associations_custom_fields_filters(*associations)
795 797 fields_by_class = CustomField.visible.where(:is_filter => true).group_by(&:class)
796 798 associations.each do |assoc|
797 799 association_klass = queried_class.reflect_on_association(assoc).klass
798 800 fields_by_class.each do |field_class, fields|
799 801 if field_class.customized_class <= association_klass
800 802 fields.sort.each do |field|
801 803 add_custom_field_filter(field, assoc)
802 804 end
803 805 end
804 806 end
805 807 end
806 808 end
807 809
808 810 # Returns a SQL clause for a date or datetime field.
809 811 def date_clause(table, field, from, to)
810 812 s = []
811 813 if from
812 from_yesterday = from - 1
813 from_yesterday_time = Time.local(from_yesterday.year, from_yesterday.month, from_yesterday.day)
814 if from.is_a?(Date)
815 from = Time.local(from.year, from.month, from.day).beginning_of_day
816 end
814 817 if self.class.default_timezone == :utc
815 from_yesterday_time = from_yesterday_time.utc
818 from = from.utc
816 819 end
817 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_time.end_of_day)])
820 from = from - 1
821 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from)])
818 822 end
819 823 if to
820 to_time = Time.local(to.year, to.month, to.day)
824 if to.is_a?(Date)
825 to = Time.local(to.year, to.month, to.day).end_of_day
826 end
821 827 if self.class.default_timezone == :utc
822 to_time = to_time.utc
828 to = to.utc
823 829 end
824 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_time.end_of_day)])
830 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to)])
825 831 end
826 832 s.join(' AND ')
827 833 end
828 834
829 835 # Returns a SQL clause for a date or datetime field using relative dates.
830 836 def relative_date_clause(table, field, days_from, days_to)
831 837 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
832 838 end
833 839
840 # Returns a Date or Time from the given filter value
841 def parse_date(arg)
842 if arg.to_s =~ /\A\d{4}-\d{2}-\d{2}T/
843 Time.parse(arg) rescue nil
844 else
845 Date.parse(arg) rescue nil
846 end
847 end
848
834 849 # Additional joins required for the given sort options
835 850 def joins_for_order_statement(order_options)
836 851 joins = []
837 852
838 853 if order_options
839 854 if order_options.include?('authors')
840 855 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{queried_table_name}.author_id"
841 856 end
842 857 order_options.scan(/cf_\d+/).uniq.each do |name|
843 858 column = available_columns.detect {|c| c.name.to_s == name}
844 859 join = column && column.custom_field.join_for_order_statement
845 860 if join
846 861 joins << join
847 862 end
848 863 end
849 864 end
850 865
851 866 joins.any? ? joins.join(' ') : nil
852 867 end
853 868 end
@@ -1,846 +1,869
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 require File.expand_path('../../../test_helper', __FILE__)
19 19
20 20 class Redmine::ApiTest::IssuesTest < Redmine::ApiTest::Base
21 21 fixtures :projects,
22 22 :users,
23 23 :roles,
24 24 :members,
25 25 :member_roles,
26 26 :issues,
27 27 :issue_statuses,
28 28 :issue_relations,
29 29 :versions,
30 30 :trackers,
31 31 :projects_trackers,
32 32 :issue_categories,
33 33 :enabled_modules,
34 34 :enumerations,
35 35 :attachments,
36 36 :workflows,
37 37 :custom_fields,
38 38 :custom_values,
39 39 :custom_fields_projects,
40 40 :custom_fields_trackers,
41 41 :time_entries,
42 42 :journals,
43 43 :journal_details,
44 44 :queries,
45 45 :attachments
46 46
47 47 def setup
48 48 Setting.rest_api_enabled = '1'
49 49 end
50 50
51 51 context "/issues" do
52 52 # Use a private project to make sure auth is really working and not just
53 53 # only showing public issues.
54 54 should_allow_api_authentication(:get, "/projects/private-child/issues.xml")
55 55
56 56 should "contain metadata" do
57 57 get '/issues.xml'
58 58
59 59 assert_tag :tag => 'issues',
60 60 :attributes => {
61 61 :type => 'array',
62 62 :total_count => assigns(:issue_count),
63 63 :limit => 25,
64 64 :offset => 0
65 65 }
66 66 end
67 67
68 68 context "with offset and limit" do
69 69 should "use the params" do
70 70 get '/issues.xml?offset=2&limit=3'
71 71
72 72 assert_equal 3, assigns(:limit)
73 73 assert_equal 2, assigns(:offset)
74 74 assert_tag :tag => 'issues', :children => {:count => 3, :only => {:tag => 'issue'}}
75 75 end
76 76 end
77 77
78 78 context "with nometa param" do
79 79 should "not contain metadata" do
80 80 get '/issues.xml?nometa=1'
81 81
82 82 assert_tag :tag => 'issues',
83 83 :attributes => {
84 84 :type => 'array',
85 85 :total_count => nil,
86 86 :limit => nil,
87 87 :offset => nil
88 88 }
89 89 end
90 90 end
91 91
92 92 context "with nometa header" do
93 93 should "not contain metadata" do
94 94 get '/issues.xml', {}, {'X-Redmine-Nometa' => '1'}
95 95
96 96 assert_tag :tag => 'issues',
97 97 :attributes => {
98 98 :type => 'array',
99 99 :total_count => nil,
100 100 :limit => nil,
101 101 :offset => nil
102 102 }
103 103 end
104 104 end
105 105
106 106 context "with relations" do
107 107 should "display relations" do
108 108 get '/issues.xml?include=relations'
109 109
110 110 assert_response :success
111 111 assert_equal 'application/xml', @response.content_type
112 112 assert_tag 'relations',
113 113 :parent => {:tag => 'issue', :child => {:tag => 'id', :content => '3'}},
114 114 :children => {:count => 1},
115 115 :child => {
116 116 :tag => 'relation',
117 117 :attributes => {:id => '2', :issue_id => '2', :issue_to_id => '3',
118 118 :relation_type => 'relates'}
119 119 }
120 120 assert_tag 'relations',
121 121 :parent => {:tag => 'issue', :child => {:tag => 'id', :content => '1'}},
122 122 :children => {:count => 0}
123 123 end
124 124 end
125 125
126 126 context "with invalid query params" do
127 127 should "return errors" do
128 128 get '/issues.xml', {:f => ['start_date'], :op => {:start_date => '='}}
129 129
130 130 assert_response :unprocessable_entity
131 131 assert_equal 'application/xml', @response.content_type
132 132 assert_tag 'errors', :child => {:tag => 'error', :content => "Start date can't be blank"}
133 133 end
134 134 end
135 135
136 136 context "with custom field filter" do
137 137 should "show only issues with the custom field value" do
138 138 get '/issues.xml',
139 139 {:set_filter => 1, :f => ['cf_1'], :op => {:cf_1 => '='},
140 140 :v => {:cf_1 => ['MySQL']}}
141 141 expected_ids = Issue.visible.
142 142 joins(:custom_values).
143 143 where(:custom_values => {:custom_field_id => 1, :value => 'MySQL'}).map(&:id)
144 144 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
145 145 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
146 146 end
147 147 end
148 148 end
149 149
150 150 context "with custom field filter (shorthand method)" do
151 151 should "show only issues with the custom field value" do
152 152 get '/issues.xml', { :cf_1 => 'MySQL' }
153 153
154 154 expected_ids = Issue.visible.
155 155 joins(:custom_values).
156 156 where(:custom_values => {:custom_field_id => 1, :value => 'MySQL'}).map(&:id)
157 157
158 158 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
159 159 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
160 160 end
161 161 end
162 162 end
163 163 end
164 164
165 def test_index_should_allow_timestamp_filtering
166 Issue.delete_all
167 Issue.generate!(:subject => '1').update_column(:updated_on, Time.parse("2014-01-02T10:25:00Z"))
168 Issue.generate!(:subject => '2').update_column(:updated_on, Time.parse("2014-01-02T12:13:00Z"))
169
170 get '/issues.xml',
171 {:set_filter => 1, :f => ['updated_on'], :op => {:updated_on => '<='},
172 :v => {:updated_on => ['2014-01-02T12:00:00Z']}}
173 assert_select 'issues>issue', :count => 1
174 assert_select 'issues>issue>subject', :text => '1'
175
176 get '/issues.xml',
177 {:set_filter => 1, :f => ['updated_on'], :op => {:updated_on => '>='},
178 :v => {:updated_on => ['2014-01-02T12:00:00Z']}}
179 assert_select 'issues>issue', :count => 1
180 assert_select 'issues>issue>subject', :text => '2'
181
182 get '/issues.xml',
183 {:set_filter => 1, :f => ['updated_on'], :op => {:updated_on => '>='},
184 :v => {:updated_on => ['2014-01-02T08:00:00Z']}}
185 assert_select 'issues>issue', :count => 2
186 end
187
165 188 context "/index.json" do
166 189 should_allow_api_authentication(:get, "/projects/private-child/issues.json")
167 190 end
168 191
169 192 context "/index.xml with filter" do
170 193 should "show only issues with the status_id" do
171 194 get '/issues.xml?status_id=5'
172 195
173 196 expected_ids = Issue.visible.where(:status_id => 5).map(&:id)
174 197
175 198 assert_select 'issues > issue > id', :count => expected_ids.count do |ids|
176 199 ids.each { |id| assert expected_ids.delete(id.children.first.content.to_i) }
177 200 end
178 201 end
179 202 end
180 203
181 204 context "/index.json with filter" do
182 205 should "show only issues with the status_id" do
183 206 get '/issues.json?status_id=5'
184 207
185 208 json = ActiveSupport::JSON.decode(response.body)
186 209 status_ids_used = json['issues'].collect {|j| j['status']['id'] }
187 210 assert_equal 3, status_ids_used.length
188 211 assert status_ids_used.all? {|id| id == 5 }
189 212 end
190 213
191 214 end
192 215
193 216 # Issue 6 is on a private project
194 217 context "/issues/6.xml" do
195 218 should_allow_api_authentication(:get, "/issues/6.xml")
196 219 end
197 220
198 221 context "/issues/6.json" do
199 222 should_allow_api_authentication(:get, "/issues/6.json")
200 223 end
201 224
202 225 context "GET /issues/:id" do
203 226 context "with journals" do
204 227 context ".xml" do
205 228 should "display journals" do
206 229 get '/issues/1.xml?include=journals'
207 230
208 231 assert_tag :tag => 'issue',
209 232 :child => {
210 233 :tag => 'journals',
211 234 :attributes => { :type => 'array' },
212 235 :child => {
213 236 :tag => 'journal',
214 237 :attributes => { :id => '1'},
215 238 :child => {
216 239 :tag => 'details',
217 240 :attributes => { :type => 'array' },
218 241 :child => {
219 242 :tag => 'detail',
220 243 :attributes => { :name => 'status_id' },
221 244 :child => {
222 245 :tag => 'old_value',
223 246 :content => '1',
224 247 :sibling => {
225 248 :tag => 'new_value',
226 249 :content => '2'
227 250 }
228 251 }
229 252 }
230 253 }
231 254 }
232 255 }
233 256 end
234 257 end
235 258 end
236 259
237 260 context "with custom fields" do
238 261 context ".xml" do
239 262 should "display custom fields" do
240 263 get '/issues/3.xml'
241 264
242 265 assert_tag :tag => 'issue',
243 266 :child => {
244 267 :tag => 'custom_fields',
245 268 :attributes => { :type => 'array' },
246 269 :child => {
247 270 :tag => 'custom_field',
248 271 :attributes => { :id => '1'},
249 272 :child => {
250 273 :tag => 'value',
251 274 :content => 'MySQL'
252 275 }
253 276 }
254 277 }
255 278
256 279 assert_nothing_raised do
257 280 Hash.from_xml(response.body).to_xml
258 281 end
259 282 end
260 283 end
261 284 end
262 285
263 286 context "with multi custom fields" do
264 287 setup do
265 288 field = CustomField.find(1)
266 289 field.update_attribute :multiple, true
267 290 issue = Issue.find(3)
268 291 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
269 292 issue.save!
270 293 end
271 294
272 295 context ".xml" do
273 296 should "display custom fields" do
274 297 get '/issues/3.xml'
275 298 assert_response :success
276 299 assert_tag :tag => 'issue',
277 300 :child => {
278 301 :tag => 'custom_fields',
279 302 :attributes => { :type => 'array' },
280 303 :child => {
281 304 :tag => 'custom_field',
282 305 :attributes => { :id => '1'},
283 306 :child => {
284 307 :tag => 'value',
285 308 :attributes => { :type => 'array' },
286 309 :children => { :count => 2 }
287 310 }
288 311 }
289 312 }
290 313
291 314 xml = Hash.from_xml(response.body)
292 315 custom_fields = xml['issue']['custom_fields']
293 316 assert_kind_of Array, custom_fields
294 317 field = custom_fields.detect {|f| f['id'] == '1'}
295 318 assert_kind_of Hash, field
296 319 assert_equal ['MySQL', 'Oracle'], field['value'].sort
297 320 end
298 321 end
299 322
300 323 context ".json" do
301 324 should "display custom fields" do
302 325 get '/issues/3.json'
303 326 assert_response :success
304 327 json = ActiveSupport::JSON.decode(response.body)
305 328 custom_fields = json['issue']['custom_fields']
306 329 assert_kind_of Array, custom_fields
307 330 field = custom_fields.detect {|f| f['id'] == 1}
308 331 assert_kind_of Hash, field
309 332 assert_equal ['MySQL', 'Oracle'], field['value'].sort
310 333 end
311 334 end
312 335 end
313 336
314 337 context "with empty value for multi custom field" do
315 338 setup do
316 339 field = CustomField.find(1)
317 340 field.update_attribute :multiple, true
318 341 issue = Issue.find(3)
319 342 issue.custom_field_values = {1 => ['']}
320 343 issue.save!
321 344 end
322 345
323 346 context ".xml" do
324 347 should "display custom fields" do
325 348 get '/issues/3.xml'
326 349 assert_response :success
327 350 assert_tag :tag => 'issue',
328 351 :child => {
329 352 :tag => 'custom_fields',
330 353 :attributes => { :type => 'array' },
331 354 :child => {
332 355 :tag => 'custom_field',
333 356 :attributes => { :id => '1'},
334 357 :child => {
335 358 :tag => 'value',
336 359 :attributes => { :type => 'array' },
337 360 :children => { :count => 0 }
338 361 }
339 362 }
340 363 }
341 364
342 365 xml = Hash.from_xml(response.body)
343 366 custom_fields = xml['issue']['custom_fields']
344 367 assert_kind_of Array, custom_fields
345 368 field = custom_fields.detect {|f| f['id'] == '1'}
346 369 assert_kind_of Hash, field
347 370 assert_equal [], field['value']
348 371 end
349 372 end
350 373
351 374 context ".json" do
352 375 should "display custom fields" do
353 376 get '/issues/3.json'
354 377 assert_response :success
355 378 json = ActiveSupport::JSON.decode(response.body)
356 379 custom_fields = json['issue']['custom_fields']
357 380 assert_kind_of Array, custom_fields
358 381 field = custom_fields.detect {|f| f['id'] == 1}
359 382 assert_kind_of Hash, field
360 383 assert_equal [], field['value'].sort
361 384 end
362 385 end
363 386 end
364 387
365 388 context "with attachments" do
366 389 context ".xml" do
367 390 should "display attachments" do
368 391 get '/issues/3.xml?include=attachments'
369 392
370 393 assert_tag :tag => 'issue',
371 394 :child => {
372 395 :tag => 'attachments',
373 396 :children => {:count => 5},
374 397 :child => {
375 398 :tag => 'attachment',
376 399 :child => {
377 400 :tag => 'filename',
378 401 :content => 'source.rb',
379 402 :sibling => {
380 403 :tag => 'content_url',
381 404 :content => 'http://www.example.com/attachments/download/4/source.rb'
382 405 }
383 406 }
384 407 }
385 408 }
386 409 end
387 410 end
388 411 end
389 412
390 413 context "with subtasks" do
391 414 setup do
392 415 @c1 = Issue.create!(
393 416 :status_id => 1, :subject => "child c1",
394 417 :tracker_id => 1, :project_id => 1, :author_id => 1,
395 418 :parent_issue_id => 1
396 419 )
397 420 @c2 = Issue.create!(
398 421 :status_id => 1, :subject => "child c2",
399 422 :tracker_id => 1, :project_id => 1, :author_id => 1,
400 423 :parent_issue_id => 1
401 424 )
402 425 @c3 = Issue.create!(
403 426 :status_id => 1, :subject => "child c3",
404 427 :tracker_id => 1, :project_id => 1, :author_id => 1,
405 428 :parent_issue_id => @c1.id
406 429 )
407 430 end
408 431
409 432 context ".xml" do
410 433 should "display children" do
411 434 get '/issues/1.xml?include=children'
412 435
413 436 assert_tag :tag => 'issue',
414 437 :child => {
415 438 :tag => 'children',
416 439 :children => {:count => 2},
417 440 :child => {
418 441 :tag => 'issue',
419 442 :attributes => {:id => @c1.id.to_s},
420 443 :child => {
421 444 :tag => 'subject',
422 445 :content => 'child c1',
423 446 :sibling => {
424 447 :tag => 'children',
425 448 :children => {:count => 1},
426 449 :child => {
427 450 :tag => 'issue',
428 451 :attributes => {:id => @c3.id.to_s}
429 452 }
430 453 }
431 454 }
432 455 }
433 456 }
434 457 end
435 458
436 459 context ".json" do
437 460 should "display children" do
438 461 get '/issues/1.json?include=children'
439 462
440 463 json = ActiveSupport::JSON.decode(response.body)
441 464 assert_equal([
442 465 {
443 466 'id' => @c1.id, 'subject' => 'child c1', 'tracker' => {'id' => 1, 'name' => 'Bug'},
444 467 'children' => [{'id' => @c3.id, 'subject' => 'child c3',
445 468 'tracker' => {'id' => 1, 'name' => 'Bug'} }]
446 469 },
447 470 { 'id' => @c2.id, 'subject' => 'child c2', 'tracker' => {'id' => 1, 'name' => 'Bug'} }
448 471 ],
449 472 json['issue']['children'])
450 473 end
451 474 end
452 475 end
453 476 end
454 477 end
455 478
456 479 test "GET /issues/:id.xml?include=watchers should include watchers" do
457 480 Watcher.create!(:user_id => 3, :watchable => Issue.find(1))
458 481
459 482 get '/issues/1.xml?include=watchers', {}, credentials('jsmith')
460 483
461 484 assert_response :ok
462 485 assert_equal 'application/xml', response.content_type
463 486 assert_select 'issue' do
464 487 assert_select 'watchers', Issue.find(1).watchers.count
465 488 assert_select 'watchers' do
466 489 assert_select 'user[id=3]'
467 490 end
468 491 end
469 492 end
470 493
471 494 context "POST /issues.xml" do
472 495 should_allow_api_authentication(
473 496 :post,
474 497 '/issues.xml',
475 498 {:issue => {:project_id => 1, :subject => 'API test', :tracker_id => 2, :status_id => 3}},
476 499 {:success_code => :created}
477 500 )
478 501 should "create an issue with the attributes" do
479 502 assert_difference('Issue.count') do
480 503 post '/issues.xml',
481 504 {:issue => {:project_id => 1, :subject => 'API test',
482 505 :tracker_id => 2, :status_id => 3}}, credentials('jsmith')
483 506 end
484 507 issue = Issue.first(:order => 'id DESC')
485 508 assert_equal 1, issue.project_id
486 509 assert_equal 2, issue.tracker_id
487 510 assert_equal 3, issue.status_id
488 511 assert_equal 'API test', issue.subject
489 512
490 513 assert_response :created
491 514 assert_equal 'application/xml', @response.content_type
492 515 assert_tag 'issue', :child => {:tag => 'id', :content => issue.id.to_s}
493 516 end
494 517 end
495 518
496 519 test "POST /issues.xml with watcher_user_ids should create issue with watchers" do
497 520 assert_difference('Issue.count') do
498 521 post '/issues.xml',
499 522 {:issue => {:project_id => 1, :subject => 'Watchers',
500 523 :tracker_id => 2, :status_id => 3, :watcher_user_ids => [3, 1]}}, credentials('jsmith')
501 524 assert_response :created
502 525 end
503 526 issue = Issue.order('id desc').first
504 527 assert_equal 2, issue.watchers.size
505 528 assert_equal [1, 3], issue.watcher_user_ids.sort
506 529 end
507 530
508 531 context "POST /issues.xml with failure" do
509 532 should "have an errors tag" do
510 533 assert_no_difference('Issue.count') do
511 534 post '/issues.xml', {:issue => {:project_id => 1}}, credentials('jsmith')
512 535 end
513 536
514 537 assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
515 538 end
516 539 end
517 540
518 541 context "POST /issues.json" do
519 542 should_allow_api_authentication(:post,
520 543 '/issues.json',
521 544 {:issue => {:project_id => 1, :subject => 'API test',
522 545 :tracker_id => 2, :status_id => 3}},
523 546 {:success_code => :created})
524 547
525 548 should "create an issue with the attributes" do
526 549 assert_difference('Issue.count') do
527 550 post '/issues.json',
528 551 {:issue => {:project_id => 1, :subject => 'API test',
529 552 :tracker_id => 2, :status_id => 3}},
530 553 credentials('jsmith')
531 554 end
532 555
533 556 issue = Issue.first(:order => 'id DESC')
534 557 assert_equal 1, issue.project_id
535 558 assert_equal 2, issue.tracker_id
536 559 assert_equal 3, issue.status_id
537 560 assert_equal 'API test', issue.subject
538 561 end
539 562
540 563 end
541 564
542 565 context "POST /issues.json with failure" do
543 566 should "have an errors element" do
544 567 assert_no_difference('Issue.count') do
545 568 post '/issues.json', {:issue => {:project_id => 1}}, credentials('jsmith')
546 569 end
547 570
548 571 json = ActiveSupport::JSON.decode(response.body)
549 572 assert json['errors'].include?("Subject can't be blank")
550 573 end
551 574 end
552 575
553 576 # Issue 6 is on a private project
554 577 context "PUT /issues/6.xml" do
555 578 setup do
556 579 @parameters = {:issue => {:subject => 'API update', :notes => 'A new note'}}
557 580 end
558 581
559 582 should_allow_api_authentication(:put,
560 583 '/issues/6.xml',
561 584 {:issue => {:subject => 'API update', :notes => 'A new note'}},
562 585 {:success_code => :ok})
563 586
564 587 should "not create a new issue" do
565 588 assert_no_difference('Issue.count') do
566 589 put '/issues/6.xml', @parameters, credentials('jsmith')
567 590 end
568 591 end
569 592
570 593 should "create a new journal" do
571 594 assert_difference('Journal.count') do
572 595 put '/issues/6.xml', @parameters, credentials('jsmith')
573 596 end
574 597 end
575 598
576 599 should "add the note to the journal" do
577 600 put '/issues/6.xml', @parameters, credentials('jsmith')
578 601
579 602 journal = Journal.last
580 603 assert_equal "A new note", journal.notes
581 604 end
582 605
583 606 should "update the issue" do
584 607 put '/issues/6.xml', @parameters, credentials('jsmith')
585 608
586 609 issue = Issue.find(6)
587 610 assert_equal "API update", issue.subject
588 611 end
589 612
590 613 end
591 614
592 615 context "PUT /issues/3.xml with custom fields" do
593 616 setup do
594 617 @parameters = {
595 618 :issue => {:custom_fields => [{'id' => '1', 'value' => 'PostgreSQL' },
596 619 {'id' => '2', 'value' => '150'}]}
597 620 }
598 621 end
599 622
600 623 should "update custom fields" do
601 624 assert_no_difference('Issue.count') do
602 625 put '/issues/3.xml', @parameters, credentials('jsmith')
603 626 end
604 627
605 628 issue = Issue.find(3)
606 629 assert_equal '150', issue.custom_value_for(2).value
607 630 assert_equal 'PostgreSQL', issue.custom_value_for(1).value
608 631 end
609 632 end
610 633
611 634 context "PUT /issues/3.xml with multi custom fields" do
612 635 setup do
613 636 field = CustomField.find(1)
614 637 field.update_attribute :multiple, true
615 638 @parameters = {
616 639 :issue => {:custom_fields => [{'id' => '1', 'value' => ['MySQL', 'PostgreSQL'] },
617 640 {'id' => '2', 'value' => '150'}]}
618 641 }
619 642 end
620 643
621 644 should "update custom fields" do
622 645 assert_no_difference('Issue.count') do
623 646 put '/issues/3.xml', @parameters, credentials('jsmith')
624 647 end
625 648
626 649 issue = Issue.find(3)
627 650 assert_equal '150', issue.custom_value_for(2).value
628 651 assert_equal ['MySQL', 'PostgreSQL'], issue.custom_field_value(1).sort
629 652 end
630 653 end
631 654
632 655 context "PUT /issues/3.xml with project change" do
633 656 setup do
634 657 @parameters = {:issue => {:project_id => 2, :subject => 'Project changed'}}
635 658 end
636 659
637 660 should "update project" do
638 661 assert_no_difference('Issue.count') do
639 662 put '/issues/3.xml', @parameters, credentials('jsmith')
640 663 end
641 664
642 665 issue = Issue.find(3)
643 666 assert_equal 2, issue.project_id
644 667 assert_equal 'Project changed', issue.subject
645 668 end
646 669 end
647 670
648 671 context "PUT /issues/6.xml with failed update" do
649 672 setup do
650 673 @parameters = {:issue => {:subject => ''}}
651 674 end
652 675
653 676 should "not create a new issue" do
654 677 assert_no_difference('Issue.count') do
655 678 put '/issues/6.xml', @parameters, credentials('jsmith')
656 679 end
657 680 end
658 681
659 682 should "not create a new journal" do
660 683 assert_no_difference('Journal.count') do
661 684 put '/issues/6.xml', @parameters, credentials('jsmith')
662 685 end
663 686 end
664 687
665 688 should "have an errors tag" do
666 689 put '/issues/6.xml', @parameters, credentials('jsmith')
667 690
668 691 assert_tag :errors, :child => {:tag => 'error', :content => "Subject can't be blank"}
669 692 end
670 693 end
671 694
672 695 context "PUT /issues/6.json" do
673 696 setup do
674 697 @parameters = {:issue => {:subject => 'API update', :notes => 'A new note'}}
675 698 end
676 699
677 700 should_allow_api_authentication(:put,
678 701 '/issues/6.json',
679 702 {:issue => {:subject => 'API update', :notes => 'A new note'}},
680 703 {:success_code => :ok})
681 704
682 705 should "update the issue" do
683 706 assert_no_difference('Issue.count') do
684 707 assert_difference('Journal.count') do
685 708 put '/issues/6.json', @parameters, credentials('jsmith')
686 709
687 710 assert_response :ok
688 711 assert_equal '', response.body
689 712 end
690 713 end
691 714
692 715 issue = Issue.find(6)
693 716 assert_equal "API update", issue.subject
694 717 journal = Journal.last
695 718 assert_equal "A new note", journal.notes
696 719 end
697 720 end
698 721
699 722 context "PUT /issues/6.json with failed update" do
700 723 should "return errors" do
701 724 assert_no_difference('Issue.count') do
702 725 assert_no_difference('Journal.count') do
703 726 put '/issues/6.json', {:issue => {:subject => ''}}, credentials('jsmith')
704 727
705 728 assert_response :unprocessable_entity
706 729 end
707 730 end
708 731
709 732 json = ActiveSupport::JSON.decode(response.body)
710 733 assert json['errors'].include?("Subject can't be blank")
711 734 end
712 735 end
713 736
714 737 context "DELETE /issues/1.xml" do
715 738 should_allow_api_authentication(:delete,
716 739 '/issues/6.xml',
717 740 {},
718 741 {:success_code => :ok})
719 742
720 743 should "delete the issue" do
721 744 assert_difference('Issue.count', -1) do
722 745 delete '/issues/6.xml', {}, credentials('jsmith')
723 746
724 747 assert_response :ok
725 748 assert_equal '', response.body
726 749 end
727 750
728 751 assert_nil Issue.find_by_id(6)
729 752 end
730 753 end
731 754
732 755 context "DELETE /issues/1.json" do
733 756 should_allow_api_authentication(:delete,
734 757 '/issues/6.json',
735 758 {},
736 759 {:success_code => :ok})
737 760
738 761 should "delete the issue" do
739 762 assert_difference('Issue.count', -1) do
740 763 delete '/issues/6.json', {}, credentials('jsmith')
741 764
742 765 assert_response :ok
743 766 assert_equal '', response.body
744 767 end
745 768
746 769 assert_nil Issue.find_by_id(6)
747 770 end
748 771 end
749 772
750 773 test "POST /issues/:id/watchers.xml should add watcher" do
751 774 assert_difference 'Watcher.count' do
752 775 post '/issues/1/watchers.xml', {:user_id => 3}, credentials('jsmith')
753 776
754 777 assert_response :ok
755 778 assert_equal '', response.body
756 779 end
757 780 watcher = Watcher.order('id desc').first
758 781 assert_equal Issue.find(1), watcher.watchable
759 782 assert_equal User.find(3), watcher.user
760 783 end
761 784
762 785 test "DELETE /issues/:id/watchers/:user_id.xml should remove watcher" do
763 786 Watcher.create!(:user_id => 3, :watchable => Issue.find(1))
764 787
765 788 assert_difference 'Watcher.count', -1 do
766 789 delete '/issues/1/watchers/3.xml', {}, credentials('jsmith')
767 790
768 791 assert_response :ok
769 792 assert_equal '', response.body
770 793 end
771 794 assert_equal false, Issue.find(1).watched_by?(User.find(3))
772 795 end
773 796
774 797 def test_create_issue_with_uploaded_file
775 798 set_tmp_attachments_directory
776 799 # upload the file
777 800 assert_difference 'Attachment.count' do
778 801 post '/uploads.xml', 'test_create_with_upload',
779 802 {"CONTENT_TYPE" => 'application/octet-stream'}.merge(credentials('jsmith'))
780 803 assert_response :created
781 804 end
782 805 xml = Hash.from_xml(response.body)
783 806 token = xml['upload']['token']
784 807 attachment = Attachment.first(:order => 'id DESC')
785 808
786 809 # create the issue with the upload's token
787 810 assert_difference 'Issue.count' do
788 811 post '/issues.xml',
789 812 {:issue => {:project_id => 1, :subject => 'Uploaded file',
790 813 :uploads => [{:token => token, :filename => 'test.txt',
791 814 :content_type => 'text/plain'}]}},
792 815 credentials('jsmith')
793 816 assert_response :created
794 817 end
795 818 issue = Issue.first(:order => 'id DESC')
796 819 assert_equal 1, issue.attachments.count
797 820 assert_equal attachment, issue.attachments.first
798 821
799 822 attachment.reload
800 823 assert_equal 'test.txt', attachment.filename
801 824 assert_equal 'text/plain', attachment.content_type
802 825 assert_equal 'test_create_with_upload'.size, attachment.filesize
803 826 assert_equal 2, attachment.author_id
804 827
805 828 # get the issue with its attachments
806 829 get "/issues/#{issue.id}.xml", :include => 'attachments'
807 830 assert_response :success
808 831 xml = Hash.from_xml(response.body)
809 832 attachments = xml['issue']['attachments']
810 833 assert_kind_of Array, attachments
811 834 assert_equal 1, attachments.size
812 835 url = attachments.first['content_url']
813 836 assert_not_nil url
814 837
815 838 # download the attachment
816 839 get url
817 840 assert_response :success
818 841 end
819 842
820 843 def test_update_issue_with_uploaded_file
821 844 set_tmp_attachments_directory
822 845 # upload the file
823 846 assert_difference 'Attachment.count' do
824 847 post '/uploads.xml', 'test_upload_with_upload',
825 848 {"CONTENT_TYPE" => 'application/octet-stream'}.merge(credentials('jsmith'))
826 849 assert_response :created
827 850 end
828 851 xml = Hash.from_xml(response.body)
829 852 token = xml['upload']['token']
830 853 attachment = Attachment.first(:order => 'id DESC')
831 854
832 855 # update the issue with the upload's token
833 856 assert_difference 'Journal.count' do
834 857 put '/issues/1.xml',
835 858 {:issue => {:notes => 'Attachment added',
836 859 :uploads => [{:token => token, :filename => 'test.txt',
837 860 :content_type => 'text/plain'}]}},
838 861 credentials('jsmith')
839 862 assert_response :ok
840 863 assert_equal '', @response.body
841 864 end
842 865
843 866 issue = Issue.find(1)
844 867 assert_include attachment, issue.attachments
845 868 end
846 869 end
General Comments 0
You need to be logged in to leave comments. Login now