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