##// END OF EJS Templates
Merged r15852 and r15863 (#23839)....
Jean-Philippe Lang -
r15483:10ac3a9c8b61
parent child
Show More
@@ -1,1026 +1,1026
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 VISIBILITY_PRIVATE = 0
148 148 VISIBILITY_ROLES = 1
149 149 VISIBILITY_PUBLIC = 2
150 150
151 151 belongs_to :project
152 152 belongs_to :user
153 153 has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}queries_roles#{table_name_suffix}", :foreign_key => "query_id"
154 154 serialize :filters
155 155 serialize :column_names
156 156 serialize :sort_criteria, Array
157 157 serialize :options, Hash
158 158
159 159 attr_protected :project_id, :user_id
160 160
161 161 validates_presence_of :name
162 162 validates_length_of :name, :maximum => 255
163 163 validates :visibility, :inclusion => { :in => [VISIBILITY_PUBLIC, VISIBILITY_ROLES, VISIBILITY_PRIVATE] }
164 164 validate :validate_query_filters
165 165 validate do |query|
166 166 errors.add(:base, l(:label_role_plural) + ' ' + l('activerecord.errors.messages.blank')) if query.visibility == VISIBILITY_ROLES && roles.blank?
167 167 end
168 168
169 169 after_save do |query|
170 170 if query.visibility_changed? && query.visibility != VISIBILITY_ROLES
171 171 query.roles.clear
172 172 end
173 173 end
174 174
175 175 class_attribute :operators
176 176 self.operators = {
177 177 "=" => :label_equals,
178 178 "!" => :label_not_equals,
179 179 "o" => :label_open_issues,
180 180 "c" => :label_closed_issues,
181 181 "!*" => :label_none,
182 182 "*" => :label_any,
183 183 ">=" => :label_greater_or_equal,
184 184 "<=" => :label_less_or_equal,
185 185 "><" => :label_between,
186 186 "<t+" => :label_in_less_than,
187 187 ">t+" => :label_in_more_than,
188 188 "><t+"=> :label_in_the_next_days,
189 189 "t+" => :label_in,
190 190 "t" => :label_today,
191 191 "ld" => :label_yesterday,
192 192 "w" => :label_this_week,
193 193 "lw" => :label_last_week,
194 194 "l2w" => [:label_last_n_weeks, {:count => 2}],
195 195 "m" => :label_this_month,
196 196 "lm" => :label_last_month,
197 197 "y" => :label_this_year,
198 198 ">t-" => :label_less_than_ago,
199 199 "<t-" => :label_more_than_ago,
200 200 "><t-"=> :label_in_the_past_days,
201 201 "t-" => :label_ago,
202 202 "~" => :label_contains,
203 203 "!~" => :label_not_contains,
204 204 "=p" => :label_any_issues_in_project,
205 205 "=!p" => :label_any_issues_not_in_project,
206 206 "!p" => :label_no_issues_in_project,
207 207 "*o" => :label_any_open_issues,
208 208 "!o" => :label_no_open_issues
209 209 }
210 210
211 211 class_attribute :operators_by_filter_type
212 212 self.operators_by_filter_type = {
213 213 :list => [ "=", "!" ],
214 214 :list_status => [ "o", "=", "!", "c", "*" ],
215 215 :list_optional => [ "=", "!", "!*", "*" ],
216 216 :list_subprojects => [ "*", "!*", "=" ],
217 217 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
218 218 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "!*", "*" ],
219 219 :string => [ "=", "~", "!", "!~", "!*", "*" ],
220 220 :text => [ "~", "!~", "!*", "*" ],
221 221 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
222 222 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
223 223 :relation => ["=", "=p", "=!p", "!p", "*o", "!o", "!*", "*"],
224 224 :tree => ["=", "~", "!*", "*"]
225 225 }
226 226
227 227 class_attribute :available_columns
228 228 self.available_columns = []
229 229
230 230 class_attribute :queried_class
231 231
232 232 def queried_table_name
233 233 @queried_table_name ||= self.class.queried_class.table_name
234 234 end
235 235
236 236 def initialize(attributes=nil, *args)
237 237 super attributes
238 238 @is_for_all = project.nil?
239 239 end
240 240
241 241 # Builds the query from the given params
242 242 def build_from_params(params)
243 243 if params[:fields] || params[:f]
244 244 self.filters = {}
245 245 add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v])
246 246 else
247 247 available_filters.keys.each do |field|
248 248 add_short_filter(field, params[field]) if params[field]
249 249 end
250 250 end
251 251 self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
252 252 self.column_names = params[:c] || (params[:query] && params[:query][:column_names])
253 253 self.totalable_names = params[:t] || (params[:query] && params[:query][:totalable_names])
254 254 self
255 255 end
256 256
257 257 # Builds a new query from the given params and attributes
258 258 def self.build_from_params(params, attributes={})
259 259 new(attributes).build_from_params(params)
260 260 end
261 261
262 262 def validate_query_filters
263 263 filters.each_key do |field|
264 264 if values_for(field)
265 265 case type_for(field)
266 266 when :integer
267 267 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+$/) }
268 268 when :float
269 269 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+(\.\d*)?$/) }
270 270 when :date, :date_past
271 271 case operator_for(field)
272 272 when "=", ">=", "<=", "><"
273 273 add_filter_error(field, :invalid) if values_for(field).detect {|v|
274 274 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?)
275 275 }
276 276 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
277 277 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
278 278 end
279 279 end
280 280 end
281 281
282 282 add_filter_error(field, :blank) unless
283 283 # filter requires one or more values
284 284 (values_for(field) and !values_for(field).first.blank?) or
285 285 # filter doesn't require any value
286 286 ["o", "c", "!*", "*", "t", "ld", "w", "lw", "l2w", "m", "lm", "y", "*o", "!o"].include? operator_for(field)
287 287 end if filters
288 288 end
289 289
290 290 def add_filter_error(field, message)
291 291 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
292 292 errors.add(:base, m)
293 293 end
294 294
295 295 def editable_by?(user)
296 296 return false unless user
297 297 # Admin can edit them all and regular users can edit their private queries
298 298 return true if user.admin? || (is_private? && self.user_id == user.id)
299 299 # Members can not edit public queries that are for all project (only admin is allowed to)
300 300 is_public? && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
301 301 end
302 302
303 303 def trackers
304 304 @trackers ||= project.nil? ? Tracker.sorted.to_a : project.rolled_up_trackers
305 305 end
306 306
307 307 # Returns a hash of localized labels for all filter operators
308 308 def self.operators_labels
309 309 operators.inject({}) {|h, operator| h[operator.first] = l(*operator.last); h}
310 310 end
311 311
312 312 # Returns a representation of the available filters for JSON serialization
313 313 def available_filters_as_json
314 314 json = {}
315 315 available_filters.each do |field, options|
316 316 options = options.slice(:type, :name, :values)
317 317 if options[:values] && values_for(field)
318 318 missing = Array(values_for(field)).select(&:present?) - options[:values].map(&:last)
319 319 if missing.any? && respond_to?(method = "find_#{field}_filter_values")
320 320 options[:values] += send(method, missing)
321 321 end
322 322 end
323 323 json[field] = options.stringify_keys
324 324 end
325 325 json
326 326 end
327 327
328 328 def all_projects
329 329 @all_projects ||= Project.visible.to_a
330 330 end
331 331
332 332 def all_projects_values
333 333 return @all_projects_values if @all_projects_values
334 334
335 335 values = []
336 336 Project.project_tree(all_projects) do |p, level|
337 337 prefix = (level > 0 ? ('--' * level + ' ') : '')
338 338 values << ["#{prefix}#{p.name}", p.id.to_s]
339 339 end
340 340 @all_projects_values = values
341 341 end
342 342
343 343 # Adds available filters
344 344 def initialize_available_filters
345 345 # implemented by sub-classes
346 346 end
347 347 protected :initialize_available_filters
348 348
349 349 # Adds an available filter
350 350 def add_available_filter(field, options)
351 351 @available_filters ||= ActiveSupport::OrderedHash.new
352 352 @available_filters[field] = options
353 353 @available_filters
354 354 end
355 355
356 356 # Removes an available filter
357 357 def delete_available_filter(field)
358 358 if @available_filters
359 359 @available_filters.delete(field)
360 360 end
361 361 end
362 362
363 363 # Return a hash of available filters
364 364 def available_filters
365 365 unless @available_filters
366 366 initialize_available_filters
367 367 @available_filters.each do |field, options|
368 368 options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
369 369 end
370 370 end
371 371 @available_filters
372 372 end
373 373
374 374 def add_filter(field, operator, values=nil)
375 375 # values must be an array
376 376 return unless values.nil? || values.is_a?(Array)
377 377 # check if field is defined as an available filter
378 378 if available_filters.has_key? field
379 379 filter_options = available_filters[field]
380 380 filters[field] = {:operator => operator, :values => (values || [''])}
381 381 end
382 382 end
383 383
384 384 def add_short_filter(field, expression)
385 385 return unless expression && available_filters.has_key?(field)
386 386 field_type = available_filters[field][:type]
387 387 operators_by_filter_type[field_type].sort.reverse.detect do |operator|
388 388 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
389 389 values = $1
390 390 add_filter field, operator, values.present? ? values.split('|') : ['']
391 391 end || add_filter(field, '=', expression.split('|'))
392 392 end
393 393
394 394 # Add multiple filters using +add_filter+
395 395 def add_filters(fields, operators, values)
396 396 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
397 397 fields.each do |field|
398 398 add_filter(field, operators[field], values && values[field])
399 399 end
400 400 end
401 401 end
402 402
403 403 def has_filter?(field)
404 404 filters and filters[field]
405 405 end
406 406
407 407 def type_for(field)
408 408 available_filters[field][:type] if available_filters.has_key?(field)
409 409 end
410 410
411 411 def operator_for(field)
412 412 has_filter?(field) ? filters[field][:operator] : nil
413 413 end
414 414
415 415 def values_for(field)
416 416 has_filter?(field) ? filters[field][:values] : nil
417 417 end
418 418
419 419 def value_for(field, index=0)
420 420 (values_for(field) || [])[index]
421 421 end
422 422
423 423 def label_for(field)
424 424 label = available_filters[field][:name] if available_filters.has_key?(field)
425 425 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
426 426 end
427 427
428 428 def self.add_available_column(column)
429 429 self.available_columns << (column) if column.is_a?(QueryColumn)
430 430 end
431 431
432 432 # Returns an array of columns that can be used to group the results
433 433 def groupable_columns
434 434 available_columns.select {|c| c.groupable}
435 435 end
436 436
437 437 # Returns a Hash of columns and the key for sorting
438 438 def sortable_columns
439 439 available_columns.inject({}) {|h, column|
440 440 h[column.name.to_s] = column.sortable
441 441 h
442 442 }
443 443 end
444 444
445 445 def columns
446 446 # preserve the column_names order
447 447 cols = (has_default_columns? ? default_columns_names : column_names).collect do |name|
448 448 available_columns.find { |col| col.name == name }
449 449 end.compact
450 450 available_columns.select(&:frozen?) | cols
451 451 end
452 452
453 453 def inline_columns
454 454 columns.select(&:inline?)
455 455 end
456 456
457 457 def block_columns
458 458 columns.reject(&:inline?)
459 459 end
460 460
461 461 def available_inline_columns
462 462 available_columns.select(&:inline?)
463 463 end
464 464
465 465 def available_block_columns
466 466 available_columns.reject(&:inline?)
467 467 end
468 468
469 469 def available_totalable_columns
470 470 available_columns.select(&:totalable)
471 471 end
472 472
473 473 def default_columns_names
474 474 []
475 475 end
476 476
477 477 def column_names=(names)
478 478 if names
479 479 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
480 480 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
481 481 # Set column_names to nil if default columns
482 482 if names == default_columns_names
483 483 names = nil
484 484 end
485 485 end
486 486 write_attribute(:column_names, names)
487 487 end
488 488
489 489 def has_column?(column)
490 490 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
491 491 end
492 492
493 493 def has_custom_field_column?
494 494 columns.any? {|column| column.is_a? QueryCustomFieldColumn}
495 495 end
496 496
497 497 def has_default_columns?
498 498 column_names.nil? || column_names.empty?
499 499 end
500 500
501 501 def totalable_columns
502 502 names = totalable_names
503 503 available_totalable_columns.select {|column| names.include?(column.name)}
504 504 end
505 505
506 506 def totalable_names=(names)
507 507 if names
508 508 names = names.select(&:present?).map {|n| n.is_a?(Symbol) ? n : n.to_sym}
509 509 end
510 510 options[:totalable_names] = names
511 511 end
512 512
513 513 def totalable_names
514 514 options[:totalable_names] || Setting.issue_list_default_totals.map(&:to_sym) || []
515 515 end
516 516
517 517 def sort_criteria=(arg)
518 518 c = []
519 519 if arg.is_a?(Hash)
520 520 arg = arg.keys.sort.collect {|k| arg[k]}
521 521 end
522 522 if arg
523 523 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
524 524 end
525 525 write_attribute(:sort_criteria, c)
526 526 end
527 527
528 528 def sort_criteria
529 529 read_attribute(:sort_criteria) || []
530 530 end
531 531
532 532 def sort_criteria_key(arg)
533 533 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
534 534 end
535 535
536 536 def sort_criteria_order(arg)
537 537 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
538 538 end
539 539
540 540 def sort_criteria_order_for(key)
541 541 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
542 542 end
543 543
544 544 # Returns the SQL sort order that should be prepended for grouping
545 545 def group_by_sort_order
546 546 if grouped? && (column = group_by_column)
547 order = (sort_criteria_order_for(column.name) || column.default_order).try(:upcase)
547 order = (sort_criteria_order_for(column.name) || column.default_order || 'asc').try(:upcase)
548 548 column.sortable.is_a?(Array) ?
549 549 column.sortable.collect {|s| "#{s} #{order}"} :
550 550 "#{column.sortable} #{order}"
551 551 end
552 552 end
553 553
554 554 # Returns true if the query is a grouped query
555 555 def grouped?
556 556 !group_by_column.nil?
557 557 end
558 558
559 559 def group_by_column
560 560 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
561 561 end
562 562
563 563 def group_by_statement
564 564 group_by_column.try(:groupable)
565 565 end
566 566
567 567 def project_statement
568 568 project_clauses = []
569 569 if project && !project.descendants.active.empty?
570 570 ids = [project.id]
571 571 if has_filter?("subproject_id")
572 572 case operator_for("subproject_id")
573 573 when '='
574 574 # include the selected subprojects
575 575 ids += values_for("subproject_id").each(&:to_i)
576 576 when '!*'
577 577 # main project only
578 578 else
579 579 # all subprojects
580 580 ids += project.descendants.collect(&:id)
581 581 end
582 582 elsif Setting.display_subprojects_issues?
583 583 ids += project.descendants.collect(&:id)
584 584 end
585 585 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
586 586 elsif project
587 587 project_clauses << "#{Project.table_name}.id = %d" % project.id
588 588 end
589 589 project_clauses.any? ? project_clauses.join(' AND ') : nil
590 590 end
591 591
592 592 def statement
593 593 # filters clauses
594 594 filters_clauses = []
595 595 filters.each_key do |field|
596 596 next if field == "subproject_id"
597 597 v = values_for(field).clone
598 598 next unless v and !v.empty?
599 599 operator = operator_for(field)
600 600
601 601 # "me" value substitution
602 602 if %w(assigned_to_id author_id user_id watcher_id).include?(field)
603 603 if v.delete("me")
604 604 if User.current.logged?
605 605 v.push(User.current.id.to_s)
606 606 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
607 607 else
608 608 v.push("0")
609 609 end
610 610 end
611 611 end
612 612
613 613 if field == 'project_id'
614 614 if v.delete('mine')
615 615 v += User.current.memberships.map(&:project_id).map(&:to_s)
616 616 end
617 617 end
618 618
619 619 if field =~ /cf_(\d+)$/
620 620 # custom field
621 621 filters_clauses << sql_for_custom_field(field, operator, v, $1)
622 622 elsif respond_to?("sql_for_#{field}_field")
623 623 # specific statement
624 624 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
625 625 else
626 626 # regular field
627 627 filters_clauses << '(' + sql_for_field(field, operator, v, queried_table_name, field) + ')'
628 628 end
629 629 end if filters and valid?
630 630
631 631 if (c = group_by_column) && c.is_a?(QueryCustomFieldColumn)
632 632 # Excludes results for which the grouped custom field is not visible
633 633 filters_clauses << c.custom_field.visibility_by_project_condition
634 634 end
635 635
636 636 filters_clauses << project_statement
637 637 filters_clauses.reject!(&:blank?)
638 638
639 639 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
640 640 end
641 641
642 642 # Returns the sum of values for the given column
643 643 def total_for(column)
644 644 total_with_scope(column, base_scope)
645 645 end
646 646
647 647 # Returns a hash of the sum of the given column for each group,
648 648 # or nil if the query is not grouped
649 649 def total_by_group_for(column)
650 650 grouped_query do |scope|
651 651 total_with_scope(column, scope)
652 652 end
653 653 end
654 654
655 655 def totals
656 656 totals = totalable_columns.map {|column| [column, total_for(column)]}
657 657 yield totals if block_given?
658 658 totals
659 659 end
660 660
661 661 def totals_by_group
662 662 totals = totalable_columns.map {|column| [column, total_by_group_for(column)]}
663 663 yield totals if block_given?
664 664 totals
665 665 end
666 666
667 667 private
668 668
669 669 def grouped_query(&block)
670 670 r = nil
671 671 if grouped?
672 672 begin
673 673 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
674 674 r = yield base_group_scope
675 675 rescue ActiveRecord::RecordNotFound
676 676 r = {nil => yield(base_scope)}
677 677 end
678 678 c = group_by_column
679 679 if c.is_a?(QueryCustomFieldColumn)
680 680 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
681 681 end
682 682 end
683 683 r
684 684 rescue ::ActiveRecord::StatementInvalid => e
685 685 raise StatementInvalid.new(e.message)
686 686 end
687 687
688 688 def total_with_scope(column, scope)
689 689 unless column.is_a?(QueryColumn)
690 690 column = column.to_sym
691 691 column = available_totalable_columns.detect {|c| c.name == column}
692 692 end
693 693 if column.is_a?(QueryCustomFieldColumn)
694 694 custom_field = column.custom_field
695 695 send "total_for_custom_field", custom_field, scope
696 696 else
697 697 send "total_for_#{column.name}", scope
698 698 end
699 699 rescue ::ActiveRecord::StatementInvalid => e
700 700 raise StatementInvalid.new(e.message)
701 701 end
702 702
703 703 def base_scope
704 704 raise "unimplemented"
705 705 end
706 706
707 707 def base_group_scope
708 708 base_scope.
709 709 joins(joins_for_order_statement(group_by_statement)).
710 710 group(group_by_statement)
711 711 end
712 712
713 713 def total_for_custom_field(custom_field, scope, &block)
714 714 total = custom_field.format.total_for_scope(custom_field, scope)
715 715 total = map_total(total) {|t| custom_field.format.cast_total_value(custom_field, t)}
716 716 total
717 717 end
718 718
719 719 def map_total(total, &block)
720 720 if total.is_a?(Hash)
721 721 total.keys.each {|k| total[k] = yield total[k]}
722 722 else
723 723 total = yield total
724 724 end
725 725 total
726 726 end
727 727
728 728 def sql_for_custom_field(field, operator, value, custom_field_id)
729 729 db_table = CustomValue.table_name
730 730 db_field = 'value'
731 731 filter = @available_filters[field]
732 732 return nil unless filter
733 733 if filter[:field].format.target_class && filter[:field].format.target_class <= User
734 734 if value.delete('me')
735 735 value.push User.current.id.to_s
736 736 end
737 737 end
738 738 not_in = nil
739 739 if operator == '!'
740 740 # Makes ! operator work for custom fields with multiple values
741 741 operator = '='
742 742 not_in = 'NOT'
743 743 end
744 744 customized_key = "id"
745 745 customized_class = queried_class
746 746 if field =~ /^(.+)\.cf_/
747 747 assoc = $1
748 748 customized_key = "#{assoc}_id"
749 749 customized_class = queried_class.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
750 750 raise "Unknown #{queried_class.name} association #{assoc}" unless customized_class
751 751 end
752 752 where = sql_for_field(field, operator, value, db_table, db_field, true)
753 753 if operator =~ /[<>]/
754 754 where = "(#{where}) AND #{db_table}.#{db_field} <> ''"
755 755 end
756 756 "#{queried_table_name}.#{customized_key} #{not_in} IN (" +
757 757 "SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name}" +
758 758 " 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}" +
759 759 " WHERE (#{where}) AND (#{filter[:field].visibility_by_project_condition}))"
760 760 end
761 761
762 762 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
763 763 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
764 764 sql = ''
765 765 case operator
766 766 when "="
767 767 if value.any?
768 768 case type_for(field)
769 769 when :date, :date_past
770 770 sql = date_clause(db_table, db_field, parse_date(value.first), parse_date(value.first), is_custom_filter)
771 771 when :integer
772 772 if is_custom_filter
773 773 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})"
774 774 else
775 775 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
776 776 end
777 777 when :float
778 778 if is_custom_filter
779 779 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})"
780 780 else
781 781 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
782 782 end
783 783 else
784 784 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{self.class.connection.quote_string(val)}'"}.join(",") + ")"
785 785 end
786 786 else
787 787 # IN an empty set
788 788 sql = "1=0"
789 789 end
790 790 when "!"
791 791 if value.any?
792 792 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{self.class.connection.quote_string(val)}'"}.join(",") + "))"
793 793 else
794 794 # NOT IN an empty set
795 795 sql = "1=1"
796 796 end
797 797 when "!*"
798 798 sql = "#{db_table}.#{db_field} IS NULL"
799 799 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
800 800 when "*"
801 801 sql = "#{db_table}.#{db_field} IS NOT NULL"
802 802 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
803 803 when ">="
804 804 if [:date, :date_past].include?(type_for(field))
805 805 sql = date_clause(db_table, db_field, parse_date(value.first), nil, is_custom_filter)
806 806 else
807 807 if is_custom_filter
808 808 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})"
809 809 else
810 810 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
811 811 end
812 812 end
813 813 when "<="
814 814 if [:date, :date_past].include?(type_for(field))
815 815 sql = date_clause(db_table, db_field, nil, parse_date(value.first), is_custom_filter)
816 816 else
817 817 if is_custom_filter
818 818 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})"
819 819 else
820 820 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
821 821 end
822 822 end
823 823 when "><"
824 824 if [:date, :date_past].include?(type_for(field))
825 825 sql = date_clause(db_table, db_field, parse_date(value[0]), parse_date(value[1]), is_custom_filter)
826 826 else
827 827 if is_custom_filter
828 828 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})"
829 829 else
830 830 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
831 831 end
832 832 end
833 833 when "o"
834 834 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"
835 835 when "c"
836 836 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"
837 837 when "><t-"
838 838 # between today - n days and today
839 839 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0, is_custom_filter)
840 840 when ">t-"
841 841 # >= today - n days
842 842 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil, is_custom_filter)
843 843 when "<t-"
844 844 # <= today - n days
845 845 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i, is_custom_filter)
846 846 when "t-"
847 847 # = n days in past
848 848 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i, is_custom_filter)
849 849 when "><t+"
850 850 # between today and today + n days
851 851 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i, is_custom_filter)
852 852 when ">t+"
853 853 # >= today + n days
854 854 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil, is_custom_filter)
855 855 when "<t+"
856 856 # <= today + n days
857 857 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i, is_custom_filter)
858 858 when "t+"
859 859 # = today + n days
860 860 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i, is_custom_filter)
861 861 when "t"
862 862 # = today
863 863 sql = relative_date_clause(db_table, db_field, 0, 0, is_custom_filter)
864 864 when "ld"
865 865 # = yesterday
866 866 sql = relative_date_clause(db_table, db_field, -1, -1, is_custom_filter)
867 867 when "w"
868 868 # = this week
869 869 first_day_of_week = l(:general_first_day_of_week).to_i
870 870 day_of_week = Date.today.cwday
871 871 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
872 872 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6, is_custom_filter)
873 873 when "lw"
874 874 # = last week
875 875 first_day_of_week = l(:general_first_day_of_week).to_i
876 876 day_of_week = Date.today.cwday
877 877 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
878 878 sql = relative_date_clause(db_table, db_field, - days_ago - 7, - days_ago - 1, is_custom_filter)
879 879 when "l2w"
880 880 # = last 2 weeks
881 881 first_day_of_week = l(:general_first_day_of_week).to_i
882 882 day_of_week = Date.today.cwday
883 883 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
884 884 sql = relative_date_clause(db_table, db_field, - days_ago - 14, - days_ago - 1, is_custom_filter)
885 885 when "m"
886 886 # = this month
887 887 date = Date.today
888 888 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month, is_custom_filter)
889 889 when "lm"
890 890 # = last month
891 891 date = Date.today.prev_month
892 892 sql = date_clause(db_table, db_field, date.beginning_of_month, date.end_of_month, is_custom_filter)
893 893 when "y"
894 894 # = this year
895 895 date = Date.today
896 896 sql = date_clause(db_table, db_field, date.beginning_of_year, date.end_of_year, is_custom_filter)
897 897 when "~"
898 898 sql = sql_contains("#{db_table}.#{db_field}", value.first)
899 899 when "!~"
900 900 sql = sql_contains("#{db_table}.#{db_field}", value.first, false)
901 901 else
902 902 raise "Unknown query operator #{operator}"
903 903 end
904 904
905 905 return sql
906 906 end
907 907
908 908 # Returns a SQL LIKE statement with wildcards
909 909 def sql_contains(db_field, value, match=true)
910 910 value = "'%#{self.class.connection.quote_string(value.to_s)}%'"
911 911 Redmine::Database.like(db_field, value, :match => match)
912 912 end
913 913
914 914 # Adds a filter for the given custom field
915 915 def add_custom_field_filter(field, assoc=nil)
916 916 options = field.query_filter_options(self)
917 917 if field.format.target_class && field.format.target_class <= User
918 918 if options[:values].is_a?(Array) && User.current.logged?
919 919 options[:values].unshift ["<< #{l(:label_me)} >>", "me"]
920 920 end
921 921 end
922 922
923 923 filter_id = "cf_#{field.id}"
924 924 filter_name = field.name
925 925 if assoc.present?
926 926 filter_id = "#{assoc}.#{filter_id}"
927 927 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
928 928 end
929 929 add_available_filter filter_id, options.merge({
930 930 :name => filter_name,
931 931 :field => field
932 932 })
933 933 end
934 934
935 935 # Adds filters for the given custom fields scope
936 936 def add_custom_fields_filters(scope, assoc=nil)
937 937 scope.visible.where(:is_filter => true).sorted.each do |field|
938 938 add_custom_field_filter(field, assoc)
939 939 end
940 940 end
941 941
942 942 # Adds filters for the given associations custom fields
943 943 def add_associations_custom_fields_filters(*associations)
944 944 fields_by_class = CustomField.visible.where(:is_filter => true).group_by(&:class)
945 945 associations.each do |assoc|
946 946 association_klass = queried_class.reflect_on_association(assoc).klass
947 947 fields_by_class.each do |field_class, fields|
948 948 if field_class.customized_class <= association_klass
949 949 fields.sort.each do |field|
950 950 add_custom_field_filter(field, assoc)
951 951 end
952 952 end
953 953 end
954 954 end
955 955 end
956 956
957 957 def quoted_time(time, is_custom_filter)
958 958 if is_custom_filter
959 959 # Custom field values are stored as strings in the DB
960 960 # using this format that does not depend on DB date representation
961 961 time.strftime("%Y-%m-%d %H:%M:%S")
962 962 else
963 963 self.class.connection.quoted_date(time)
964 964 end
965 965 end
966 966
967 967 # Returns a SQL clause for a date or datetime field.
968 968 def date_clause(table, field, from, to, is_custom_filter)
969 969 s = []
970 970 if from
971 971 if from.is_a?(Date)
972 972 from = Time.local(from.year, from.month, from.day).yesterday.end_of_day
973 973 else
974 974 from = from - 1 # second
975 975 end
976 976 if self.class.default_timezone == :utc
977 977 from = from.utc
978 978 end
979 979 s << ("#{table}.#{field} > '%s'" % [quoted_time(from, is_custom_filter)])
980 980 end
981 981 if to
982 982 if to.is_a?(Date)
983 983 to = Time.local(to.year, to.month, to.day).end_of_day
984 984 end
985 985 if self.class.default_timezone == :utc
986 986 to = to.utc
987 987 end
988 988 s << ("#{table}.#{field} <= '%s'" % [quoted_time(to, is_custom_filter)])
989 989 end
990 990 s.join(' AND ')
991 991 end
992 992
993 993 # Returns a SQL clause for a date or datetime field using relative dates.
994 994 def relative_date_clause(table, field, days_from, days_to, is_custom_filter)
995 995 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil), is_custom_filter)
996 996 end
997 997
998 998 # Returns a Date or Time from the given filter value
999 999 def parse_date(arg)
1000 1000 if arg.to_s =~ /\A\d{4}-\d{2}-\d{2}T/
1001 1001 Time.parse(arg) rescue nil
1002 1002 else
1003 1003 Date.parse(arg) rescue nil
1004 1004 end
1005 1005 end
1006 1006
1007 1007 # Additional joins required for the given sort options
1008 1008 def joins_for_order_statement(order_options)
1009 1009 joins = []
1010 1010
1011 1011 if order_options
1012 1012 if order_options.include?('authors')
1013 1013 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{queried_table_name}.author_id"
1014 1014 end
1015 1015 order_options.scan(/cf_\d+/).uniq.each do |name|
1016 1016 column = available_columns.detect {|c| c.name.to_s == name}
1017 1017 join = column && column.custom_field.join_for_order_statement
1018 1018 if join
1019 1019 joins << join
1020 1020 end
1021 1021 end
1022 1022 end
1023 1023
1024 1024 joins.any? ? joins.join(' ') : nil
1025 1025 end
1026 1026 end
@@ -1,218 +1,259
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 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class IssuesTest < Redmine::IntegrationTest
21 21 fixtures :projects,
22 22 :users, :email_addresses,
23 23 :roles,
24 24 :members,
25 25 :member_roles,
26 26 :trackers,
27 27 :projects_trackers,
28 28 :enabled_modules,
29 29 :issue_statuses,
30 30 :issues,
31 31 :enumerations,
32 32 :custom_fields,
33 33 :custom_values,
34 34 :custom_fields_trackers,
35 35 :attachments
36 36
37 37 # create an issue
38 38 def test_add_issue
39 39 log_user('jsmith', 'jsmith')
40 40
41 41 get '/projects/ecookbook/issues/new'
42 42 assert_response :success
43 43 assert_template 'issues/new'
44 44
45 45 issue = new_record(Issue) do
46 46 post '/projects/ecookbook/issues',
47 47 :issue => { :tracker_id => "1",
48 48 :start_date => "2006-12-26",
49 49 :priority_id => "4",
50 50 :subject => "new test issue",
51 51 :category_id => "",
52 52 :description => "new issue",
53 53 :done_ratio => "0",
54 54 :due_date => "",
55 55 :assigned_to_id => "" },
56 56 :custom_fields => {'2' => 'Value for field 2'}
57 57 end
58 58 # check redirection
59 59 assert_redirected_to :controller => 'issues', :action => 'show', :id => issue
60 60 follow_redirect!
61 61 assert_equal issue, assigns(:issue)
62 62
63 63 # check issue attributes
64 64 assert_equal 'jsmith', issue.author.login
65 65 assert_equal 1, issue.project.id
66 66 assert_equal 1, issue.status.id
67 67 end
68 68
69 69 def test_create_issue_by_anonymous_without_permission_should_fail
70 70 Role.anonymous.remove_permission! :add_issues
71 71
72 72 assert_no_difference 'Issue.count' do
73 73 post '/projects/1/issues', :tracker_id => "1", :issue => {:subject => "new test issue"}
74 74 end
75 75 assert_response 302
76 76 end
77 77
78 78 def test_create_issue_by_anonymous_with_custom_permission_should_succeed
79 79 Role.anonymous.remove_permission! :add_issues
80 80 Member.create!(:project_id => 1, :principal => Group.anonymous, :role_ids => [3])
81 81
82 82 issue = new_record(Issue) do
83 83 post '/projects/1/issues', :tracker_id => "1", :issue => {:subject => "new test issue"}
84 84 assert_response 302
85 85 end
86 86 assert_equal User.anonymous, issue.author
87 87 end
88 88
89 89 # add then remove 2 attachments to an issue
90 90 def test_issue_attachments
91 91 log_user('jsmith', 'jsmith')
92 92 set_tmp_attachments_directory
93 93
94 94 attachment = new_record(Attachment) do
95 95 put '/issues/1',
96 96 :notes => 'Some notes',
97 97 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain'), 'description' => 'This is an attachment'}}
98 98 assert_redirected_to "/issues/1"
99 99 end
100 100
101 101 assert_equal Issue.find(1), attachment.container
102 102 assert_equal 'testfile.txt', attachment.filename
103 103 assert_equal 'This is an attachment', attachment.description
104 104 # verify the size of the attachment stored in db
105 105 #assert_equal file_data_1.length, attachment.filesize
106 106 # verify that the attachment was written to disk
107 107 assert File.exist?(attachment.diskfile)
108 108
109 109 # remove the attachments
110 110 Issue.find(1).attachments.each(&:destroy)
111 111 assert_equal 0, Issue.find(1).attachments.length
112 112 end
113 113
114 def test_next_and_previous_links_should_be_displayed_after_query_grouped_and_sorted_by_version
115 with_settings :default_language => 'en' do
116 get '/projects/ecookbook/issues?set_filter=1&group_by=fixed_version&sort=priority:desc,fixed_version,id'
117 assert_response :success
118 assert_select 'td.id', :text => '5'
119
120 get '/issues/5'
121 assert_response :success
122 assert_select '.next-prev-links .position', :text => '5 of 6'
123 end
124 end
125
126 def test_next_and_previous_links_should_be_displayed_after_filter
127 with_settings :default_language => 'en' do
128 get '/projects/ecookbook/issues?set_filter=1&tracker_id=1'
129 assert_response :success
130 assert_select 'td.id', :text => '5'
131
132 get '/issues/5'
133 assert_response :success
134 assert_select '.next-prev-links .position', :text => '3 of 5'
135 end
136 end
137
138 def test_next_and_previous_links_should_be_displayed_after_saved_query
139 query = IssueQuery.create!(:name => 'Calendar Query',
140 :visibility => IssueQuery::VISIBILITY_PUBLIC,
141 :filters => {'tracker_id' => {:operator => '=', :values => ['1']}}
142 )
143
144 with_settings :default_language => 'en' do
145 get "/projects/ecookbook/issues?set_filter=1&query_id=#{query.id}"
146 assert_response :success
147 assert_select 'td.id', :text => '5'
148
149 get '/issues/5'
150 assert_response :success
151 assert_select '.next-prev-links .position', :text => '6 of 8'
152 end
153 end
154
114 155 def test_other_formats_links_on_index
115 156 get '/projects/ecookbook/issues'
116 157
117 158 %w(Atom PDF CSV).each do |format|
118 159 assert_select 'a[rel=nofollow][href=?]', "/projects/ecookbook/issues.#{format.downcase}", :text => format
119 160 end
120 161 end
121 162
122 163 def test_other_formats_links_on_index_without_project_id_in_url
123 164 get '/issues', :project_id => 'ecookbook'
124 165
125 166 %w(Atom PDF CSV).each do |format|
126 167 assert_select 'a[rel=nofollow][href=?]', "/projects/ecookbook/issues.#{format.downcase}", :text => format
127 168 end
128 169 end
129 170
130 171 def test_pagination_links_on_index
131 172 with_settings :per_page_options => '2' do
132 173 get '/projects/ecookbook/issues'
133 174
134 175 assert_select 'a[href=?]', '/projects/ecookbook/issues?page=2', :text => '2'
135 176 end
136 177 end
137 178
138 179 def test_pagination_links_on_index_without_project_id_in_url
139 180 with_settings :per_page_options => '2' do
140 181 get '/issues', :project_id => 'ecookbook'
141 182
142 183 assert_select 'a[href=?]', '/projects/ecookbook/issues?page=2', :text => '2'
143 184 end
144 185 end
145 186
146 187 def test_issue_with_user_custom_field
147 188 @field = IssueCustomField.create!(:name => 'Tester', :field_format => 'user', :is_for_all => true, :trackers => Tracker.all)
148 189 Role.anonymous.add_permission! :add_issues, :edit_issues
149 190 users = Project.find(1).users.uniq.sort
150 191 tester = users.first
151 192
152 193 # Issue form
153 194 get '/projects/ecookbook/issues/new'
154 195 assert_response :success
155 196 assert_select 'select[name=?]', "issue[custom_field_values][#{@field.id}]" do
156 197 assert_select 'option', users.size + 1 # +1 for blank value
157 198 assert_select 'option[value=?]', tester.id.to_s, :text => tester.name
158 199 end
159 200
160 201 # Create issue
161 202 issue = new_record(Issue) do
162 203 post '/projects/ecookbook/issues',
163 204 :issue => {
164 205 :tracker_id => '1',
165 206 :priority_id => '4',
166 207 :subject => 'Issue with user custom field',
167 208 :custom_field_values => {@field.id.to_s => users.first.id.to_s}
168 209 }
169 210 assert_response 302
170 211 end
171 212
172 213 # Issue view
173 214 follow_redirect!
174 215 assert_select ".cf_#{@field.id}" do
175 216 assert_select '.label', :text => 'Tester:'
176 217 assert_select '.value', :text => tester.name
177 218 end
178 219 assert_select 'select[name=?]', "issue[custom_field_values][#{@field.id}]" do
179 220 assert_select 'option', users.size + 1 # +1 for blank value
180 221 assert_select 'option[value=?][selected=selected]', tester.id.to_s, :text => tester.name
181 222 end
182 223
183 224 new_tester = users[1]
184 225 with_settings :default_language => 'en' do
185 226 # Update issue
186 227 assert_difference 'Journal.count' do
187 228 put "/issues/#{issue.id}",
188 229 :notes => 'Updating custom field',
189 230 :issue => {
190 231 :custom_field_values => {@field.id.to_s => new_tester.id.to_s}
191 232 }
192 233 assert_redirected_to "/issues/#{issue.id}"
193 234 end
194 235 # Issue view
195 236 follow_redirect!
196 237 assert_select 'ul.details li', :text => "Tester changed from #{tester} to #{new_tester}"
197 238 end
198 239 end
199 240
200 241 def test_update_using_invalid_http_verbs
201 242 subject = 'Updated by an invalid http verb'
202 243
203 244 get '/issues/update/1', {:issue => {:subject => subject}}, credentials('jsmith')
204 245 assert_response 404
205 246 assert_not_equal subject, Issue.find(1).subject
206 247
207 248 post '/issues/1', {:issue => {:subject => subject}}, credentials('jsmith')
208 249 assert_response 404
209 250 assert_not_equal subject, Issue.find(1).subject
210 251 end
211 252
212 253 def test_get_watch_should_be_invalid
213 254 assert_no_difference 'Watcher.count' do
214 255 get '/watchers/watch?object_type=issue&object_id=1', {}, credentials('jsmith')
215 256 assert_response 404
216 257 end
217 258 end
218 259 end
General Comments 0
You need to be logged in to leave comments. Login now