##// END OF EJS Templates
Merged r3409 from trunk....
Jean-Philippe Lang -
r3350:13fb739b5620
parent child
Show More
@@ -1,559 +1,563
1 # Redmine - project management software
1 # Redmine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
2 # Copyright (C) 2006-2008 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 end
30 end
31
31
32 def caption
32 def caption
33 l("field_#{name}")
33 l("field_#{name}")
34 end
34 end
35
35
36 # Returns true if the column is sortable, otherwise false
36 # Returns true if the column is sortable, otherwise false
37 def sortable?
37 def sortable?
38 !sortable.nil?
38 !sortable.nil?
39 end
39 end
40
40
41 def value(issue)
41 def value(issue)
42 issue.send name
42 issue.send name
43 end
43 end
44 end
44 end
45
45
46 class QueryCustomFieldColumn < QueryColumn
46 class QueryCustomFieldColumn < QueryColumn
47
47
48 def initialize(custom_field)
48 def initialize(custom_field)
49 self.name = "cf_#{custom_field.id}".to_sym
49 self.name = "cf_#{custom_field.id}".to_sym
50 self.sortable = custom_field.order_statement || false
50 self.sortable = custom_field.order_statement || false
51 if %w(list date bool int).include?(custom_field.field_format)
51 if %w(list date bool int).include?(custom_field.field_format)
52 self.groupable = custom_field.order_statement
52 self.groupable = custom_field.order_statement
53 end
53 end
54 self.groupable ||= false
54 self.groupable ||= false
55 @cf = custom_field
55 @cf = custom_field
56 end
56 end
57
57
58 def caption
58 def caption
59 @cf.name
59 @cf.name
60 end
60 end
61
61
62 def custom_field
62 def custom_field
63 @cf
63 @cf
64 end
64 end
65
65
66 def value(issue)
66 def value(issue)
67 cv = issue.custom_values.detect {|v| v.custom_field_id == @cf.id}
67 cv = issue.custom_values.detect {|v| v.custom_field_id == @cf.id}
68 cv && @cf.cast_value(cv.value)
68 cv && @cf.cast_value(cv.value)
69 end
69 end
70 end
70 end
71
71
72 class Query < ActiveRecord::Base
72 class Query < ActiveRecord::Base
73 class StatementInvalid < ::ActiveRecord::StatementInvalid
73 class StatementInvalid < ::ActiveRecord::StatementInvalid
74 end
74 end
75
75
76 belongs_to :project
76 belongs_to :project
77 belongs_to :user
77 belongs_to :user
78 serialize :filters
78 serialize :filters
79 serialize :column_names
79 serialize :column_names
80 serialize :sort_criteria, Array
80 serialize :sort_criteria, Array
81
81
82 attr_protected :project_id, :user_id
82 attr_protected :project_id, :user_id
83
83
84 validates_presence_of :name, :on => :save
84 validates_presence_of :name, :on => :save
85 validates_length_of :name, :maximum => 255
85 validates_length_of :name, :maximum => 255
86
86
87 @@operators = { "=" => :label_equals,
87 @@operators = { "=" => :label_equals,
88 "!" => :label_not_equals,
88 "!" => :label_not_equals,
89 "o" => :label_open_issues,
89 "o" => :label_open_issues,
90 "c" => :label_closed_issues,
90 "c" => :label_closed_issues,
91 "!*" => :label_none,
91 "!*" => :label_none,
92 "*" => :label_all,
92 "*" => :label_all,
93 ">=" => :label_greater_or_equal,
93 ">=" => :label_greater_or_equal,
94 "<=" => :label_less_or_equal,
94 "<=" => :label_less_or_equal,
95 "<t+" => :label_in_less_than,
95 "<t+" => :label_in_less_than,
96 ">t+" => :label_in_more_than,
96 ">t+" => :label_in_more_than,
97 "t+" => :label_in,
97 "t+" => :label_in,
98 "t" => :label_today,
98 "t" => :label_today,
99 "w" => :label_this_week,
99 "w" => :label_this_week,
100 ">t-" => :label_less_than_ago,
100 ">t-" => :label_less_than_ago,
101 "<t-" => :label_more_than_ago,
101 "<t-" => :label_more_than_ago,
102 "t-" => :label_ago,
102 "t-" => :label_ago,
103 "~" => :label_contains,
103 "~" => :label_contains,
104 "!~" => :label_not_contains }
104 "!~" => :label_not_contains }
105
105
106 cattr_reader :operators
106 cattr_reader :operators
107
107
108 @@operators_by_filter_type = { :list => [ "=", "!" ],
108 @@operators_by_filter_type = { :list => [ "=", "!" ],
109 :list_status => [ "o", "=", "!", "c", "*" ],
109 :list_status => [ "o", "=", "!", "c", "*" ],
110 :list_optional => [ "=", "!", "!*", "*" ],
110 :list_optional => [ "=", "!", "!*", "*" ],
111 :list_subprojects => [ "*", "!*", "=" ],
111 :list_subprojects => [ "*", "!*", "=" ],
112 :date => [ "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-" ],
112 :date => [ "<t+", ">t+", "t+", "t", "w", ">t-", "<t-", "t-" ],
113 :date_past => [ ">t-", "<t-", "t-", "t", "w" ],
113 :date_past => [ ">t-", "<t-", "t-", "t", "w" ],
114 :string => [ "=", "~", "!", "!~" ],
114 :string => [ "=", "~", "!", "!~" ],
115 :text => [ "~", "!~" ],
115 :text => [ "~", "!~" ],
116 :integer => [ "=", ">=", "<=", "!*", "*" ] }
116 :integer => [ "=", ">=", "<=", "!*", "*" ] }
117
117
118 cattr_reader :operators_by_filter_type
118 cattr_reader :operators_by_filter_type
119
119
120 @@available_columns = [
120 @@available_columns = [
121 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
121 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
122 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
122 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
123 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
123 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
124 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
124 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
125 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
125 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
126 QueryColumn.new(:author),
126 QueryColumn.new(:author),
127 QueryColumn.new(:assigned_to, :sortable => ["#{User.table_name}.lastname", "#{User.table_name}.firstname", "#{User.table_name}.id"], :groupable => true),
127 QueryColumn.new(:assigned_to, :sortable => ["#{User.table_name}.lastname", "#{User.table_name}.firstname", "#{User.table_name}.id"], :groupable => true),
128 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
128 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
129 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
129 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
130 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
130 QueryColumn.new(:fixed_version, :sortable => ["#{Version.table_name}.effective_date", "#{Version.table_name}.name"], :default_order => 'desc', :groupable => true),
131 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
131 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
132 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
132 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
133 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
133 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
134 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
134 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
135 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
135 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
136 ]
136 ]
137 cattr_reader :available_columns
137 cattr_reader :available_columns
138
138
139 def initialize(attributes = nil)
139 def initialize(attributes = nil)
140 super attributes
140 super attributes
141 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
141 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
142 end
142 end
143
143
144 def after_initialize
144 def after_initialize
145 # Store the fact that project is nil (used in #editable_by?)
145 # Store the fact that project is nil (used in #editable_by?)
146 @is_for_all = project.nil?
146 @is_for_all = project.nil?
147 end
147 end
148
148
149 def validate
149 def validate
150 filters.each_key do |field|
150 filters.each_key do |field|
151 errors.add label_for(field), :blank unless
151 errors.add label_for(field), :blank unless
152 # filter requires one or more values
152 # filter requires one or more values
153 (values_for(field) and !values_for(field).first.blank?) or
153 (values_for(field) and !values_for(field).first.blank?) or
154 # filter doesn't require any value
154 # filter doesn't require any value
155 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
155 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
156 end if filters
156 end if filters
157 end
157 end
158
158
159 def editable_by?(user)
159 def editable_by?(user)
160 return false unless user
160 return false unless user
161 # Admin can edit them all and regular users can edit their private queries
161 # Admin can edit them all and regular users can edit their private queries
162 return true if user.admin? || (!is_public && self.user_id == user.id)
162 return true if user.admin? || (!is_public && self.user_id == user.id)
163 # Members can not edit public queries that are for all project (only admin is allowed to)
163 # Members can not edit public queries that are for all project (only admin is allowed to)
164 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
164 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
165 end
165 end
166
166
167 def available_filters
167 def available_filters
168 return @available_filters if @available_filters
168 return @available_filters if @available_filters
169
169
170 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
170 trackers = project.nil? ? Tracker.find(:all, :order => 'position') : project.rolled_up_trackers
171
171
172 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
172 @available_filters = { "status_id" => { :type => :list_status, :order => 1, :values => IssueStatus.find(:all, :order => 'position').collect{|s| [s.name, s.id.to_s] } },
173 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
173 "tracker_id" => { :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] } },
174 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
174 "priority_id" => { :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] } },
175 "subject" => { :type => :text, :order => 8 },
175 "subject" => { :type => :text, :order => 8 },
176 "created_on" => { :type => :date_past, :order => 9 },
176 "created_on" => { :type => :date_past, :order => 9 },
177 "updated_on" => { :type => :date_past, :order => 10 },
177 "updated_on" => { :type => :date_past, :order => 10 },
178 "start_date" => { :type => :date, :order => 11 },
178 "start_date" => { :type => :date, :order => 11 },
179 "due_date" => { :type => :date, :order => 12 },
179 "due_date" => { :type => :date, :order => 12 },
180 "estimated_hours" => { :type => :integer, :order => 13 },
180 "estimated_hours" => { :type => :integer, :order => 13 },
181 "done_ratio" => { :type => :integer, :order => 14 }}
181 "done_ratio" => { :type => :integer, :order => 14 }}
182
182
183 user_values = []
183 user_values = []
184 user_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
184 user_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
185 if project
185 if project
186 user_values += project.users.sort.collect{|s| [s.name, s.id.to_s] }
186 user_values += project.users.sort.collect{|s| [s.name, s.id.to_s] }
187 else
187 else
188 # members of the user's projects
188 # members of the user's projects
189 # OPTIMIZE: Is selecting from users per project (N+1)
189 # OPTIMIZE: Is selecting from users per project (N+1)
190 user_values += User.current.projects.collect(&:users).flatten.uniq.sort.collect{|s| [s.name, s.id.to_s] }
190 user_values += User.current.projects.collect(&:users).flatten.uniq.sort.collect{|s| [s.name, s.id.to_s] }
191 end
191 end
192 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => user_values } unless user_values.empty?
192 @available_filters["assigned_to_id"] = { :type => :list_optional, :order => 4, :values => user_values } unless user_values.empty?
193 @available_filters["author_id"] = { :type => :list, :order => 5, :values => user_values } unless user_values.empty?
193 @available_filters["author_id"] = { :type => :list, :order => 5, :values => user_values } unless user_values.empty?
194
194
195 if User.current.logged?
195 if User.current.logged?
196 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
196 @available_filters["watcher_id"] = { :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]] }
197 end
197 end
198
198
199 if project
199 if project
200 # project specific filters
200 # project specific filters
201 unless @project.issue_categories.empty?
201 unless @project.issue_categories.empty?
202 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => @project.issue_categories.collect{|s| [s.name, s.id.to_s] } }
202 @available_filters["category_id"] = { :type => :list_optional, :order => 6, :values => @project.issue_categories.collect{|s| [s.name, s.id.to_s] } }
203 end
203 end
204 unless @project.shared_versions.empty?
204 unless @project.shared_versions.empty?
205 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => @project.shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
205 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => @project.shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
206 end
206 end
207 unless @project.descendants.active.empty?
207 unless @project.descendants.active.empty?
208 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.descendants.visible.collect{|s| [s.name, s.id.to_s] } }
208 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.descendants.visible.collect{|s| [s.name, s.id.to_s] } }
209 end
209 end
210 add_custom_fields_filters(@project.all_issue_custom_fields)
210 add_custom_fields_filters(@project.all_issue_custom_fields)
211 else
211 else
212 # global filters for cross project issue list
212 # global filters for cross project issue list
213 system_shared_versions = Version.visible.find_all_by_sharing('system')
214 unless system_shared_versions.empty?
215 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => system_shared_versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] } }
216 end
213 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
217 add_custom_fields_filters(IssueCustomField.find(:all, :conditions => {:is_filter => true, :is_for_all => true}))
214 end
218 end
215 @available_filters
219 @available_filters
216 end
220 end
217
221
218 def add_filter(field, operator, values)
222 def add_filter(field, operator, values)
219 # values must be an array
223 # values must be an array
220 return unless values and values.is_a? Array # and !values.first.empty?
224 return unless values and values.is_a? Array # and !values.first.empty?
221 # check if field is defined as an available filter
225 # check if field is defined as an available filter
222 if available_filters.has_key? field
226 if available_filters.has_key? field
223 filter_options = available_filters[field]
227 filter_options = available_filters[field]
224 # check if operator is allowed for that filter
228 # check if operator is allowed for that filter
225 #if @@operators_by_filter_type[filter_options[:type]].include? operator
229 #if @@operators_by_filter_type[filter_options[:type]].include? operator
226 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
230 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
227 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
231 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
228 #end
232 #end
229 filters[field] = {:operator => operator, :values => values }
233 filters[field] = {:operator => operator, :values => values }
230 end
234 end
231 end
235 end
232
236
233 def add_short_filter(field, expression)
237 def add_short_filter(field, expression)
234 return unless expression
238 return unless expression
235 parms = expression.scan(/^(o|c|!\*|!|\*)?(.*)$/).first
239 parms = expression.scan(/^(o|c|!\*|!|\*)?(.*)$/).first
236 add_filter field, (parms[0] || "="), [parms[1] || ""]
240 add_filter field, (parms[0] || "="), [parms[1] || ""]
237 end
241 end
238
242
239 def has_filter?(field)
243 def has_filter?(field)
240 filters and filters[field]
244 filters and filters[field]
241 end
245 end
242
246
243 def operator_for(field)
247 def operator_for(field)
244 has_filter?(field) ? filters[field][:operator] : nil
248 has_filter?(field) ? filters[field][:operator] : nil
245 end
249 end
246
250
247 def values_for(field)
251 def values_for(field)
248 has_filter?(field) ? filters[field][:values] : nil
252 has_filter?(field) ? filters[field][:values] : nil
249 end
253 end
250
254
251 def label_for(field)
255 def label_for(field)
252 label = available_filters[field][:name] if available_filters.has_key?(field)
256 label = available_filters[field][:name] if available_filters.has_key?(field)
253 label ||= field.gsub(/\_id$/, "")
257 label ||= field.gsub(/\_id$/, "")
254 end
258 end
255
259
256 def available_columns
260 def available_columns
257 return @available_columns if @available_columns
261 return @available_columns if @available_columns
258 @available_columns = Query.available_columns
262 @available_columns = Query.available_columns
259 @available_columns += (project ?
263 @available_columns += (project ?
260 project.all_issue_custom_fields :
264 project.all_issue_custom_fields :
261 IssueCustomField.find(:all)
265 IssueCustomField.find(:all)
262 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
266 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
263 end
267 end
264
268
265 # Returns an array of columns that can be used to group the results
269 # Returns an array of columns that can be used to group the results
266 def groupable_columns
270 def groupable_columns
267 available_columns.select {|c| c.groupable}
271 available_columns.select {|c| c.groupable}
268 end
272 end
269
273
270 def columns
274 def columns
271 if has_default_columns?
275 if has_default_columns?
272 available_columns.select do |c|
276 available_columns.select do |c|
273 # Adds the project column by default for cross-project lists
277 # Adds the project column by default for cross-project lists
274 Setting.issue_list_default_columns.include?(c.name.to_s) || (c.name == :project && project.nil?)
278 Setting.issue_list_default_columns.include?(c.name.to_s) || (c.name == :project && project.nil?)
275 end
279 end
276 else
280 else
277 # preserve the column_names order
281 # preserve the column_names order
278 column_names.collect {|name| available_columns.find {|col| col.name == name}}.compact
282 column_names.collect {|name| available_columns.find {|col| col.name == name}}.compact
279 end
283 end
280 end
284 end
281
285
282 def column_names=(names)
286 def column_names=(names)
283 if names
287 if names
284 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
288 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
285 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
289 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
286 # Set column_names to nil if default columns
290 # Set column_names to nil if default columns
287 if names.map(&:to_s) == Setting.issue_list_default_columns
291 if names.map(&:to_s) == Setting.issue_list_default_columns
288 names = nil
292 names = nil
289 end
293 end
290 end
294 end
291 write_attribute(:column_names, names)
295 write_attribute(:column_names, names)
292 end
296 end
293
297
294 def has_column?(column)
298 def has_column?(column)
295 column_names && column_names.include?(column.name)
299 column_names && column_names.include?(column.name)
296 end
300 end
297
301
298 def has_default_columns?
302 def has_default_columns?
299 column_names.nil? || column_names.empty?
303 column_names.nil? || column_names.empty?
300 end
304 end
301
305
302 def sort_criteria=(arg)
306 def sort_criteria=(arg)
303 c = []
307 c = []
304 if arg.is_a?(Hash)
308 if arg.is_a?(Hash)
305 arg = arg.keys.sort.collect {|k| arg[k]}
309 arg = arg.keys.sort.collect {|k| arg[k]}
306 end
310 end
307 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
311 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, o == 'desc' ? o : 'asc']}
308 write_attribute(:sort_criteria, c)
312 write_attribute(:sort_criteria, c)
309 end
313 end
310
314
311 def sort_criteria
315 def sort_criteria
312 read_attribute(:sort_criteria) || []
316 read_attribute(:sort_criteria) || []
313 end
317 end
314
318
315 def sort_criteria_key(arg)
319 def sort_criteria_key(arg)
316 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
320 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
317 end
321 end
318
322
319 def sort_criteria_order(arg)
323 def sort_criteria_order(arg)
320 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
324 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
321 end
325 end
322
326
323 # Returns the SQL sort order that should be prepended for grouping
327 # Returns the SQL sort order that should be prepended for grouping
324 def group_by_sort_order
328 def group_by_sort_order
325 if grouped? && (column = group_by_column)
329 if grouped? && (column = group_by_column)
326 column.sortable.is_a?(Array) ?
330 column.sortable.is_a?(Array) ?
327 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
331 column.sortable.collect {|s| "#{s} #{column.default_order}"}.join(',') :
328 "#{column.sortable} #{column.default_order}"
332 "#{column.sortable} #{column.default_order}"
329 end
333 end
330 end
334 end
331
335
332 # Returns true if the query is a grouped query
336 # Returns true if the query is a grouped query
333 def grouped?
337 def grouped?
334 !group_by.blank?
338 !group_by.blank?
335 end
339 end
336
340
337 def group_by_column
341 def group_by_column
338 groupable_columns.detect {|c| c.name.to_s == group_by}
342 groupable_columns.detect {|c| c.name.to_s == group_by}
339 end
343 end
340
344
341 def group_by_statement
345 def group_by_statement
342 group_by_column.groupable
346 group_by_column.groupable
343 end
347 end
344
348
345 def project_statement
349 def project_statement
346 project_clauses = []
350 project_clauses = []
347 if project && !@project.descendants.active.empty?
351 if project && !@project.descendants.active.empty?
348 ids = [project.id]
352 ids = [project.id]
349 if has_filter?("subproject_id")
353 if has_filter?("subproject_id")
350 case operator_for("subproject_id")
354 case operator_for("subproject_id")
351 when '='
355 when '='
352 # include the selected subprojects
356 # include the selected subprojects
353 ids += values_for("subproject_id").each(&:to_i)
357 ids += values_for("subproject_id").each(&:to_i)
354 when '!*'
358 when '!*'
355 # main project only
359 # main project only
356 else
360 else
357 # all subprojects
361 # all subprojects
358 ids += project.descendants.collect(&:id)
362 ids += project.descendants.collect(&:id)
359 end
363 end
360 elsif Setting.display_subprojects_issues?
364 elsif Setting.display_subprojects_issues?
361 ids += project.descendants.collect(&:id)
365 ids += project.descendants.collect(&:id)
362 end
366 end
363 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
367 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
364 elsif project
368 elsif project
365 project_clauses << "#{Project.table_name}.id = %d" % project.id
369 project_clauses << "#{Project.table_name}.id = %d" % project.id
366 end
370 end
367 project_clauses << Project.allowed_to_condition(User.current, :view_issues)
371 project_clauses << Project.allowed_to_condition(User.current, :view_issues)
368 project_clauses.join(' AND ')
372 project_clauses.join(' AND ')
369 end
373 end
370
374
371 def statement
375 def statement
372 # filters clauses
376 # filters clauses
373 filters_clauses = []
377 filters_clauses = []
374 filters.each_key do |field|
378 filters.each_key do |field|
375 next if field == "subproject_id"
379 next if field == "subproject_id"
376 v = values_for(field).clone
380 v = values_for(field).clone
377 next unless v and !v.empty?
381 next unless v and !v.empty?
378 operator = operator_for(field)
382 operator = operator_for(field)
379
383
380 # "me" value subsitution
384 # "me" value subsitution
381 if %w(assigned_to_id author_id watcher_id).include?(field)
385 if %w(assigned_to_id author_id watcher_id).include?(field)
382 v.push(User.current.logged? ? User.current.id.to_s : "0") if v.delete("me")
386 v.push(User.current.logged? ? User.current.id.to_s : "0") if v.delete("me")
383 end
387 end
384
388
385 sql = ''
389 sql = ''
386 if field =~ /^cf_(\d+)$/
390 if field =~ /^cf_(\d+)$/
387 # custom field
391 # custom field
388 db_table = CustomValue.table_name
392 db_table = CustomValue.table_name
389 db_field = 'value'
393 db_field = 'value'
390 is_custom_filter = true
394 is_custom_filter = true
391 sql << "#{Issue.table_name}.id IN (SELECT #{Issue.table_name}.id FROM #{Issue.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='Issue' AND #{db_table}.customized_id=#{Issue.table_name}.id AND #{db_table}.custom_field_id=#{$1} WHERE "
395 sql << "#{Issue.table_name}.id IN (SELECT #{Issue.table_name}.id FROM #{Issue.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='Issue' AND #{db_table}.customized_id=#{Issue.table_name}.id AND #{db_table}.custom_field_id=#{$1} WHERE "
392 sql << sql_for_field(field, operator, v, db_table, db_field, true) + ')'
396 sql << sql_for_field(field, operator, v, db_table, db_field, true) + ')'
393 elsif field == 'watcher_id'
397 elsif field == 'watcher_id'
394 db_table = Watcher.table_name
398 db_table = Watcher.table_name
395 db_field = 'user_id'
399 db_field = 'user_id'
396 sql << "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND "
400 sql << "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND "
397 sql << sql_for_field(field, '=', v, db_table, db_field) + ')'
401 sql << sql_for_field(field, '=', v, db_table, db_field) + ')'
398 else
402 else
399 # regular field
403 # regular field
400 db_table = Issue.table_name
404 db_table = Issue.table_name
401 db_field = field
405 db_field = field
402 sql << '(' + sql_for_field(field, operator, v, db_table, db_field) + ')'
406 sql << '(' + sql_for_field(field, operator, v, db_table, db_field) + ')'
403 end
407 end
404 filters_clauses << sql
408 filters_clauses << sql
405
409
406 end if filters and valid?
410 end if filters and valid?
407
411
408 (filters_clauses << project_statement).join(' AND ')
412 (filters_clauses << project_statement).join(' AND ')
409 end
413 end
410
414
411 # Returns the issue count
415 # Returns the issue count
412 def issue_count
416 def issue_count
413 Issue.count(:include => [:status, :project], :conditions => statement)
417 Issue.count(:include => [:status, :project], :conditions => statement)
414 rescue ::ActiveRecord::StatementInvalid => e
418 rescue ::ActiveRecord::StatementInvalid => e
415 raise StatementInvalid.new(e.message)
419 raise StatementInvalid.new(e.message)
416 end
420 end
417
421
418 # Returns the issue count by group or nil if query is not grouped
422 # Returns the issue count by group or nil if query is not grouped
419 def issue_count_by_group
423 def issue_count_by_group
420 r = nil
424 r = nil
421 if grouped?
425 if grouped?
422 begin
426 begin
423 # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value
427 # Rails will raise an (unexpected) RecordNotFound if there's only a nil group value
424 r = Issue.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
428 r = Issue.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
425 rescue ActiveRecord::RecordNotFound
429 rescue ActiveRecord::RecordNotFound
426 r = {nil => issue_count}
430 r = {nil => issue_count}
427 end
431 end
428 c = group_by_column
432 c = group_by_column
429 if c.is_a?(QueryCustomFieldColumn)
433 if c.is_a?(QueryCustomFieldColumn)
430 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
434 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
431 end
435 end
432 end
436 end
433 r
437 r
434 rescue ::ActiveRecord::StatementInvalid => e
438 rescue ::ActiveRecord::StatementInvalid => e
435 raise StatementInvalid.new(e.message)
439 raise StatementInvalid.new(e.message)
436 end
440 end
437
441
438 # Returns the issues
442 # Returns the issues
439 # Valid options are :order, :offset, :limit, :include, :conditions
443 # Valid options are :order, :offset, :limit, :include, :conditions
440 def issues(options={})
444 def issues(options={})
441 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
445 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
442 order_option = nil if order_option.blank?
446 order_option = nil if order_option.blank?
443
447
444 Issue.find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
448 Issue.find :all, :include => ([:status, :project] + (options[:include] || [])).uniq,
445 :conditions => Query.merge_conditions(statement, options[:conditions]),
449 :conditions => Query.merge_conditions(statement, options[:conditions]),
446 :order => order_option,
450 :order => order_option,
447 :limit => options[:limit],
451 :limit => options[:limit],
448 :offset => options[:offset]
452 :offset => options[:offset]
449 rescue ::ActiveRecord::StatementInvalid => e
453 rescue ::ActiveRecord::StatementInvalid => e
450 raise StatementInvalid.new(e.message)
454 raise StatementInvalid.new(e.message)
451 end
455 end
452
456
453 # Returns the journals
457 # Returns the journals
454 # Valid options are :order, :offset, :limit
458 # Valid options are :order, :offset, :limit
455 def journals(options={})
459 def journals(options={})
456 Journal.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
460 Journal.find :all, :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
457 :conditions => statement,
461 :conditions => statement,
458 :order => options[:order],
462 :order => options[:order],
459 :limit => options[:limit],
463 :limit => options[:limit],
460 :offset => options[:offset]
464 :offset => options[:offset]
461 rescue ::ActiveRecord::StatementInvalid => e
465 rescue ::ActiveRecord::StatementInvalid => e
462 raise StatementInvalid.new(e.message)
466 raise StatementInvalid.new(e.message)
463 end
467 end
464
468
465 # Returns the versions
469 # Returns the versions
466 # Valid options are :conditions
470 # Valid options are :conditions
467 def versions(options={})
471 def versions(options={})
468 Version.find :all, :include => :project,
472 Version.find :all, :include => :project,
469 :conditions => Query.merge_conditions(project_statement, options[:conditions])
473 :conditions => Query.merge_conditions(project_statement, options[:conditions])
470 rescue ::ActiveRecord::StatementInvalid => e
474 rescue ::ActiveRecord::StatementInvalid => e
471 raise StatementInvalid.new(e.message)
475 raise StatementInvalid.new(e.message)
472 end
476 end
473
477
474 private
478 private
475
479
476 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
480 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
477 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
481 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
478 sql = ''
482 sql = ''
479 case operator
483 case operator
480 when "="
484 when "="
481 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
485 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
482 when "!"
486 when "!"
483 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
487 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
484 when "!*"
488 when "!*"
485 sql = "#{db_table}.#{db_field} IS NULL"
489 sql = "#{db_table}.#{db_field} IS NULL"
486 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
490 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
487 when "*"
491 when "*"
488 sql = "#{db_table}.#{db_field} IS NOT NULL"
492 sql = "#{db_table}.#{db_field} IS NOT NULL"
489 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
493 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
490 when ">="
494 when ">="
491 sql = "#{db_table}.#{db_field} >= #{value.first.to_i}"
495 sql = "#{db_table}.#{db_field} >= #{value.first.to_i}"
492 when "<="
496 when "<="
493 sql = "#{db_table}.#{db_field} <= #{value.first.to_i}"
497 sql = "#{db_table}.#{db_field} <= #{value.first.to_i}"
494 when "o"
498 when "o"
495 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_false}" if field == "status_id"
499 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_false}" if field == "status_id"
496 when "c"
500 when "c"
497 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_true}" if field == "status_id"
501 sql = "#{IssueStatus.table_name}.is_closed=#{connection.quoted_true}" if field == "status_id"
498 when ">t-"
502 when ">t-"
499 sql = date_range_clause(db_table, db_field, - value.first.to_i, 0)
503 sql = date_range_clause(db_table, db_field, - value.first.to_i, 0)
500 when "<t-"
504 when "<t-"
501 sql = date_range_clause(db_table, db_field, nil, - value.first.to_i)
505 sql = date_range_clause(db_table, db_field, nil, - value.first.to_i)
502 when "t-"
506 when "t-"
503 sql = date_range_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
507 sql = date_range_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
504 when ">t+"
508 when ">t+"
505 sql = date_range_clause(db_table, db_field, value.first.to_i, nil)
509 sql = date_range_clause(db_table, db_field, value.first.to_i, nil)
506 when "<t+"
510 when "<t+"
507 sql = date_range_clause(db_table, db_field, 0, value.first.to_i)
511 sql = date_range_clause(db_table, db_field, 0, value.first.to_i)
508 when "t+"
512 when "t+"
509 sql = date_range_clause(db_table, db_field, value.first.to_i, value.first.to_i)
513 sql = date_range_clause(db_table, db_field, value.first.to_i, value.first.to_i)
510 when "t"
514 when "t"
511 sql = date_range_clause(db_table, db_field, 0, 0)
515 sql = date_range_clause(db_table, db_field, 0, 0)
512 when "w"
516 when "w"
513 from = l(:general_first_day_of_week) == '7' ?
517 from = l(:general_first_day_of_week) == '7' ?
514 # week starts on sunday
518 # week starts on sunday
515 ((Date.today.cwday == 7) ? Time.now.at_beginning_of_day : Time.now.at_beginning_of_week - 1.day) :
519 ((Date.today.cwday == 7) ? Time.now.at_beginning_of_day : Time.now.at_beginning_of_week - 1.day) :
516 # week starts on monday (Rails default)
520 # week starts on monday (Rails default)
517 Time.now.at_beginning_of_week
521 Time.now.at_beginning_of_week
518 sql = "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date(from), connection.quoted_date(from + 7.days)]
522 sql = "#{db_table}.#{db_field} BETWEEN '%s' AND '%s'" % [connection.quoted_date(from), connection.quoted_date(from + 7.days)]
519 when "~"
523 when "~"
520 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
524 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
521 when "!~"
525 when "!~"
522 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
526 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
523 end
527 end
524
528
525 return sql
529 return sql
526 end
530 end
527
531
528 def add_custom_fields_filters(custom_fields)
532 def add_custom_fields_filters(custom_fields)
529 @available_filters ||= {}
533 @available_filters ||= {}
530
534
531 custom_fields.select(&:is_filter?).each do |field|
535 custom_fields.select(&:is_filter?).each do |field|
532 case field.field_format
536 case field.field_format
533 when "text"
537 when "text"
534 options = { :type => :text, :order => 20 }
538 options = { :type => :text, :order => 20 }
535 when "list"
539 when "list"
536 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
540 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
537 when "date"
541 when "date"
538 options = { :type => :date, :order => 20 }
542 options = { :type => :date, :order => 20 }
539 when "bool"
543 when "bool"
540 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
544 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
541 else
545 else
542 options = { :type => :string, :order => 20 }
546 options = { :type => :string, :order => 20 }
543 end
547 end
544 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name })
548 @available_filters["cf_#{field.id}"] = options.merge({ :name => field.name })
545 end
549 end
546 end
550 end
547
551
548 # Returns a SQL clause for a date or datetime field.
552 # Returns a SQL clause for a date or datetime field.
549 def date_range_clause(table, field, from, to)
553 def date_range_clause(table, field, from, to)
550 s = []
554 s = []
551 if from
555 if from
552 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date((Date.yesterday + from).to_time.end_of_day)])
556 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date((Date.yesterday + from).to_time.end_of_day)])
553 end
557 end
554 if to
558 if to
555 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date((Date.today + to).to_time.end_of_day)])
559 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date((Date.today + to).to_time.end_of_day)])
556 end
560 end
557 s.join(' AND ')
561 s.join(' AND ')
558 end
562 end
559 end
563 end
@@ -1,347 +1,354
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
2 # Copyright (C) 2006-2008 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.dirname(__FILE__) + '/../test_helper'
18 require File.dirname(__FILE__) + '/../test_helper'
19
19
20 class QueryTest < ActiveSupport::TestCase
20 class QueryTest < ActiveSupport::TestCase
21 fixtures :projects, :enabled_modules, :users, :members, :member_roles, :roles, :trackers, :issue_statuses, :issue_categories, :enumerations, :issues, :watchers, :custom_fields, :custom_values, :versions, :queries
21 fixtures :projects, :enabled_modules, :users, :members, :member_roles, :roles, :trackers, :issue_statuses, :issue_categories, :enumerations, :issues, :watchers, :custom_fields, :custom_values, :versions, :queries
22
22
23 def test_custom_fields_for_all_projects_should_be_available_in_global_queries
23 def test_custom_fields_for_all_projects_should_be_available_in_global_queries
24 query = Query.new(:project => nil, :name => '_')
24 query = Query.new(:project => nil, :name => '_')
25 assert query.available_filters.has_key?('cf_1')
25 assert query.available_filters.has_key?('cf_1')
26 assert !query.available_filters.has_key?('cf_3')
26 assert !query.available_filters.has_key?('cf_3')
27 end
27 end
28
28
29 def test_system_shared_versions_should_be_available_in_global_queries
30 Version.find(2).update_attribute :sharing, 'system'
31 query = Query.new(:project => nil, :name => '_')
32 assert query.available_filters.has_key?('fixed_version_id')
33 assert query.available_filters['fixed_version_id'][:values].detect {|v| v.last == '2'}
34 end
35
29 def find_issues_with_query(query)
36 def find_issues_with_query(query)
30 Issue.find :all,
37 Issue.find :all,
31 :include => [ :assigned_to, :status, :tracker, :project, :priority ],
38 :include => [ :assigned_to, :status, :tracker, :project, :priority ],
32 :conditions => query.statement
39 :conditions => query.statement
33 end
40 end
34
41
35 def test_query_should_allow_shared_versions_for_a_project_query
42 def test_query_should_allow_shared_versions_for_a_project_query
36 subproject_version = Version.find(4)
43 subproject_version = Version.find(4)
37 query = Query.new(:project => Project.find(1), :name => '_')
44 query = Query.new(:project => Project.find(1), :name => '_')
38 query.add_filter('fixed_version_id', '=', [subproject_version.id.to_s])
45 query.add_filter('fixed_version_id', '=', [subproject_version.id.to_s])
39
46
40 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IN ('4')")
47 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IN ('4')")
41 end
48 end
42
49
43 def test_query_with_multiple_custom_fields
50 def test_query_with_multiple_custom_fields
44 query = Query.find(1)
51 query = Query.find(1)
45 assert query.valid?
52 assert query.valid?
46 assert query.statement.include?("#{CustomValue.table_name}.value IN ('MySQL')")
53 assert query.statement.include?("#{CustomValue.table_name}.value IN ('MySQL')")
47 issues = find_issues_with_query(query)
54 issues = find_issues_with_query(query)
48 assert_equal 1, issues.length
55 assert_equal 1, issues.length
49 assert_equal Issue.find(3), issues.first
56 assert_equal Issue.find(3), issues.first
50 end
57 end
51
58
52 def test_operator_none
59 def test_operator_none
53 query = Query.new(:project => Project.find(1), :name => '_')
60 query = Query.new(:project => Project.find(1), :name => '_')
54 query.add_filter('fixed_version_id', '!*', [''])
61 query.add_filter('fixed_version_id', '!*', [''])
55 query.add_filter('cf_1', '!*', [''])
62 query.add_filter('cf_1', '!*', [''])
56 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IS NULL")
63 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IS NULL")
57 assert query.statement.include?("#{CustomValue.table_name}.value IS NULL OR #{CustomValue.table_name}.value = ''")
64 assert query.statement.include?("#{CustomValue.table_name}.value IS NULL OR #{CustomValue.table_name}.value = ''")
58 find_issues_with_query(query)
65 find_issues_with_query(query)
59 end
66 end
60
67
61 def test_operator_none_for_integer
68 def test_operator_none_for_integer
62 query = Query.new(:project => Project.find(1), :name => '_')
69 query = Query.new(:project => Project.find(1), :name => '_')
63 query.add_filter('estimated_hours', '!*', [''])
70 query.add_filter('estimated_hours', '!*', [''])
64 issues = find_issues_with_query(query)
71 issues = find_issues_with_query(query)
65 assert !issues.empty?
72 assert !issues.empty?
66 assert issues.all? {|i| !i.estimated_hours}
73 assert issues.all? {|i| !i.estimated_hours}
67 end
74 end
68
75
69 def test_operator_all
76 def test_operator_all
70 query = Query.new(:project => Project.find(1), :name => '_')
77 query = Query.new(:project => Project.find(1), :name => '_')
71 query.add_filter('fixed_version_id', '*', [''])
78 query.add_filter('fixed_version_id', '*', [''])
72 query.add_filter('cf_1', '*', [''])
79 query.add_filter('cf_1', '*', [''])
73 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IS NOT NULL")
80 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IS NOT NULL")
74 assert query.statement.include?("#{CustomValue.table_name}.value IS NOT NULL AND #{CustomValue.table_name}.value <> ''")
81 assert query.statement.include?("#{CustomValue.table_name}.value IS NOT NULL AND #{CustomValue.table_name}.value <> ''")
75 find_issues_with_query(query)
82 find_issues_with_query(query)
76 end
83 end
77
84
78 def test_operator_greater_than
85 def test_operator_greater_than
79 query = Query.new(:project => Project.find(1), :name => '_')
86 query = Query.new(:project => Project.find(1), :name => '_')
80 query.add_filter('done_ratio', '>=', ['40'])
87 query.add_filter('done_ratio', '>=', ['40'])
81 assert query.statement.include?("#{Issue.table_name}.done_ratio >= 40")
88 assert query.statement.include?("#{Issue.table_name}.done_ratio >= 40")
82 find_issues_with_query(query)
89 find_issues_with_query(query)
83 end
90 end
84
91
85 def test_operator_in_more_than
92 def test_operator_in_more_than
86 Issue.find(7).update_attribute(:due_date, (Date.today + 15))
93 Issue.find(7).update_attribute(:due_date, (Date.today + 15))
87 query = Query.new(:project => Project.find(1), :name => '_')
94 query = Query.new(:project => Project.find(1), :name => '_')
88 query.add_filter('due_date', '>t+', ['15'])
95 query.add_filter('due_date', '>t+', ['15'])
89 issues = find_issues_with_query(query)
96 issues = find_issues_with_query(query)
90 assert !issues.empty?
97 assert !issues.empty?
91 issues.each {|issue| assert(issue.due_date >= (Date.today + 15))}
98 issues.each {|issue| assert(issue.due_date >= (Date.today + 15))}
92 end
99 end
93
100
94 def test_operator_in_less_than
101 def test_operator_in_less_than
95 query = Query.new(:project => Project.find(1), :name => '_')
102 query = Query.new(:project => Project.find(1), :name => '_')
96 query.add_filter('due_date', '<t+', ['15'])
103 query.add_filter('due_date', '<t+', ['15'])
97 issues = find_issues_with_query(query)
104 issues = find_issues_with_query(query)
98 assert !issues.empty?
105 assert !issues.empty?
99 issues.each {|issue| assert(issue.due_date >= Date.today && issue.due_date <= (Date.today + 15))}
106 issues.each {|issue| assert(issue.due_date >= Date.today && issue.due_date <= (Date.today + 15))}
100 end
107 end
101
108
102 def test_operator_less_than_ago
109 def test_operator_less_than_ago
103 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
110 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
104 query = Query.new(:project => Project.find(1), :name => '_')
111 query = Query.new(:project => Project.find(1), :name => '_')
105 query.add_filter('due_date', '>t-', ['3'])
112 query.add_filter('due_date', '>t-', ['3'])
106 issues = find_issues_with_query(query)
113 issues = find_issues_with_query(query)
107 assert !issues.empty?
114 assert !issues.empty?
108 issues.each {|issue| assert(issue.due_date >= (Date.today - 3) && issue.due_date <= Date.today)}
115 issues.each {|issue| assert(issue.due_date >= (Date.today - 3) && issue.due_date <= Date.today)}
109 end
116 end
110
117
111 def test_operator_more_than_ago
118 def test_operator_more_than_ago
112 Issue.find(7).update_attribute(:due_date, (Date.today - 10))
119 Issue.find(7).update_attribute(:due_date, (Date.today - 10))
113 query = Query.new(:project => Project.find(1), :name => '_')
120 query = Query.new(:project => Project.find(1), :name => '_')
114 query.add_filter('due_date', '<t-', ['10'])
121 query.add_filter('due_date', '<t-', ['10'])
115 assert query.statement.include?("#{Issue.table_name}.due_date <=")
122 assert query.statement.include?("#{Issue.table_name}.due_date <=")
116 issues = find_issues_with_query(query)
123 issues = find_issues_with_query(query)
117 assert !issues.empty?
124 assert !issues.empty?
118 issues.each {|issue| assert(issue.due_date <= (Date.today - 10))}
125 issues.each {|issue| assert(issue.due_date <= (Date.today - 10))}
119 end
126 end
120
127
121 def test_operator_in
128 def test_operator_in
122 Issue.find(7).update_attribute(:due_date, (Date.today + 2))
129 Issue.find(7).update_attribute(:due_date, (Date.today + 2))
123 query = Query.new(:project => Project.find(1), :name => '_')
130 query = Query.new(:project => Project.find(1), :name => '_')
124 query.add_filter('due_date', 't+', ['2'])
131 query.add_filter('due_date', 't+', ['2'])
125 issues = find_issues_with_query(query)
132 issues = find_issues_with_query(query)
126 assert !issues.empty?
133 assert !issues.empty?
127 issues.each {|issue| assert_equal((Date.today + 2), issue.due_date)}
134 issues.each {|issue| assert_equal((Date.today + 2), issue.due_date)}
128 end
135 end
129
136
130 def test_operator_ago
137 def test_operator_ago
131 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
138 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
132 query = Query.new(:project => Project.find(1), :name => '_')
139 query = Query.new(:project => Project.find(1), :name => '_')
133 query.add_filter('due_date', 't-', ['3'])
140 query.add_filter('due_date', 't-', ['3'])
134 issues = find_issues_with_query(query)
141 issues = find_issues_with_query(query)
135 assert !issues.empty?
142 assert !issues.empty?
136 issues.each {|issue| assert_equal((Date.today - 3), issue.due_date)}
143 issues.each {|issue| assert_equal((Date.today - 3), issue.due_date)}
137 end
144 end
138
145
139 def test_operator_today
146 def test_operator_today
140 query = Query.new(:project => Project.find(1), :name => '_')
147 query = Query.new(:project => Project.find(1), :name => '_')
141 query.add_filter('due_date', 't', [''])
148 query.add_filter('due_date', 't', [''])
142 issues = find_issues_with_query(query)
149 issues = find_issues_with_query(query)
143 assert !issues.empty?
150 assert !issues.empty?
144 issues.each {|issue| assert_equal Date.today, issue.due_date}
151 issues.each {|issue| assert_equal Date.today, issue.due_date}
145 end
152 end
146
153
147 def test_operator_this_week_on_date
154 def test_operator_this_week_on_date
148 query = Query.new(:project => Project.find(1), :name => '_')
155 query = Query.new(:project => Project.find(1), :name => '_')
149 query.add_filter('due_date', 'w', [''])
156 query.add_filter('due_date', 'w', [''])
150 find_issues_with_query(query)
157 find_issues_with_query(query)
151 end
158 end
152
159
153 def test_operator_this_week_on_datetime
160 def test_operator_this_week_on_datetime
154 query = Query.new(:project => Project.find(1), :name => '_')
161 query = Query.new(:project => Project.find(1), :name => '_')
155 query.add_filter('created_on', 'w', [''])
162 query.add_filter('created_on', 'w', [''])
156 find_issues_with_query(query)
163 find_issues_with_query(query)
157 end
164 end
158
165
159 def test_operator_contains
166 def test_operator_contains
160 query = Query.new(:project => Project.find(1), :name => '_')
167 query = Query.new(:project => Project.find(1), :name => '_')
161 query.add_filter('subject', '~', ['uNable'])
168 query.add_filter('subject', '~', ['uNable'])
162 assert query.statement.include?("LOWER(#{Issue.table_name}.subject) LIKE '%unable%'")
169 assert query.statement.include?("LOWER(#{Issue.table_name}.subject) LIKE '%unable%'")
163 result = find_issues_with_query(query)
170 result = find_issues_with_query(query)
164 assert result.empty?
171 assert result.empty?
165 result.each {|issue| assert issue.subject.downcase.include?('unable') }
172 result.each {|issue| assert issue.subject.downcase.include?('unable') }
166 end
173 end
167
174
168 def test_operator_does_not_contains
175 def test_operator_does_not_contains
169 query = Query.new(:project => Project.find(1), :name => '_')
176 query = Query.new(:project => Project.find(1), :name => '_')
170 query.add_filter('subject', '!~', ['uNable'])
177 query.add_filter('subject', '!~', ['uNable'])
171 assert query.statement.include?("LOWER(#{Issue.table_name}.subject) NOT LIKE '%unable%'")
178 assert query.statement.include?("LOWER(#{Issue.table_name}.subject) NOT LIKE '%unable%'")
172 find_issues_with_query(query)
179 find_issues_with_query(query)
173 end
180 end
174
181
175 def test_filter_watched_issues
182 def test_filter_watched_issues
176 User.current = User.find(1)
183 User.current = User.find(1)
177 query = Query.new(:name => '_', :filters => { 'watcher_id' => {:operator => '=', :values => ['me']}})
184 query = Query.new(:name => '_', :filters => { 'watcher_id' => {:operator => '=', :values => ['me']}})
178 result = find_issues_with_query(query)
185 result = find_issues_with_query(query)
179 assert_not_nil result
186 assert_not_nil result
180 assert !result.empty?
187 assert !result.empty?
181 assert_equal Issue.visible.watched_by(User.current).sort_by(&:id), result.sort_by(&:id)
188 assert_equal Issue.visible.watched_by(User.current).sort_by(&:id), result.sort_by(&:id)
182 User.current = nil
189 User.current = nil
183 end
190 end
184
191
185 def test_filter_unwatched_issues
192 def test_filter_unwatched_issues
186 User.current = User.find(1)
193 User.current = User.find(1)
187 query = Query.new(:name => '_', :filters => { 'watcher_id' => {:operator => '!', :values => ['me']}})
194 query = Query.new(:name => '_', :filters => { 'watcher_id' => {:operator => '!', :values => ['me']}})
188 result = find_issues_with_query(query)
195 result = find_issues_with_query(query)
189 assert_not_nil result
196 assert_not_nil result
190 assert !result.empty?
197 assert !result.empty?
191 assert_equal((Issue.visible - Issue.watched_by(User.current)).sort_by(&:id).size, result.sort_by(&:id).size)
198 assert_equal((Issue.visible - Issue.watched_by(User.current)).sort_by(&:id).size, result.sort_by(&:id).size)
192 User.current = nil
199 User.current = nil
193 end
200 end
194
201
195 def test_default_columns
202 def test_default_columns
196 q = Query.new
203 q = Query.new
197 assert !q.columns.empty?
204 assert !q.columns.empty?
198 end
205 end
199
206
200 def test_set_column_names
207 def test_set_column_names
201 q = Query.new
208 q = Query.new
202 q.column_names = ['tracker', :subject, '', 'unknonw_column']
209 q.column_names = ['tracker', :subject, '', 'unknonw_column']
203 assert_equal [:tracker, :subject], q.columns.collect {|c| c.name}
210 assert_equal [:tracker, :subject], q.columns.collect {|c| c.name}
204 c = q.columns.first
211 c = q.columns.first
205 assert q.has_column?(c)
212 assert q.has_column?(c)
206 end
213 end
207
214
208 def test_groupable_columns_should_include_custom_fields
215 def test_groupable_columns_should_include_custom_fields
209 q = Query.new
216 q = Query.new
210 assert q.groupable_columns.detect {|c| c.is_a? QueryCustomFieldColumn}
217 assert q.groupable_columns.detect {|c| c.is_a? QueryCustomFieldColumn}
211 end
218 end
212
219
213 def test_default_sort
220 def test_default_sort
214 q = Query.new
221 q = Query.new
215 assert_equal [], q.sort_criteria
222 assert_equal [], q.sort_criteria
216 end
223 end
217
224
218 def test_set_sort_criteria_with_hash
225 def test_set_sort_criteria_with_hash
219 q = Query.new
226 q = Query.new
220 q.sort_criteria = {'0' => ['priority', 'desc'], '2' => ['tracker']}
227 q.sort_criteria = {'0' => ['priority', 'desc'], '2' => ['tracker']}
221 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
228 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
222 end
229 end
223
230
224 def test_set_sort_criteria_with_array
231 def test_set_sort_criteria_with_array
225 q = Query.new
232 q = Query.new
226 q.sort_criteria = [['priority', 'desc'], 'tracker']
233 q.sort_criteria = [['priority', 'desc'], 'tracker']
227 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
234 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
228 end
235 end
229
236
230 def test_create_query_with_sort
237 def test_create_query_with_sort
231 q = Query.new(:name => 'Sorted')
238 q = Query.new(:name => 'Sorted')
232 q.sort_criteria = [['priority', 'desc'], 'tracker']
239 q.sort_criteria = [['priority', 'desc'], 'tracker']
233 assert q.save
240 assert q.save
234 q.reload
241 q.reload
235 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
242 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
236 end
243 end
237
244
238 def test_sort_by_string_custom_field_asc
245 def test_sort_by_string_custom_field_asc
239 q = Query.new
246 q = Query.new
240 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'string' }
247 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'string' }
241 assert c
248 assert c
242 assert c.sortable
249 assert c.sortable
243 issues = Issue.find :all,
250 issues = Issue.find :all,
244 :include => [ :assigned_to, :status, :tracker, :project, :priority ],
251 :include => [ :assigned_to, :status, :tracker, :project, :priority ],
245 :conditions => q.statement,
252 :conditions => q.statement,
246 :order => "#{c.sortable} ASC"
253 :order => "#{c.sortable} ASC"
247 values = issues.collect {|i| i.custom_value_for(c.custom_field).to_s}
254 values = issues.collect {|i| i.custom_value_for(c.custom_field).to_s}
248 assert !values.empty?
255 assert !values.empty?
249 assert_equal values.sort, values
256 assert_equal values.sort, values
250 end
257 end
251
258
252 def test_sort_by_string_custom_field_desc
259 def test_sort_by_string_custom_field_desc
253 q = Query.new
260 q = Query.new
254 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'string' }
261 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'string' }
255 assert c
262 assert c
256 assert c.sortable
263 assert c.sortable
257 issues = Issue.find :all,
264 issues = Issue.find :all,
258 :include => [ :assigned_to, :status, :tracker, :project, :priority ],
265 :include => [ :assigned_to, :status, :tracker, :project, :priority ],
259 :conditions => q.statement,
266 :conditions => q.statement,
260 :order => "#{c.sortable} DESC"
267 :order => "#{c.sortable} DESC"
261 values = issues.collect {|i| i.custom_value_for(c.custom_field).to_s}
268 values = issues.collect {|i| i.custom_value_for(c.custom_field).to_s}
262 assert !values.empty?
269 assert !values.empty?
263 assert_equal values.sort.reverse, values
270 assert_equal values.sort.reverse, values
264 end
271 end
265
272
266 def test_sort_by_float_custom_field_asc
273 def test_sort_by_float_custom_field_asc
267 q = Query.new
274 q = Query.new
268 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'float' }
275 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'float' }
269 assert c
276 assert c
270 assert c.sortable
277 assert c.sortable
271 issues = Issue.find :all,
278 issues = Issue.find :all,
272 :include => [ :assigned_to, :status, :tracker, :project, :priority ],
279 :include => [ :assigned_to, :status, :tracker, :project, :priority ],
273 :conditions => q.statement,
280 :conditions => q.statement,
274 :order => "#{c.sortable} ASC"
281 :order => "#{c.sortable} ASC"
275 values = issues.collect {|i| begin; Kernel.Float(i.custom_value_for(c.custom_field).to_s); rescue; nil; end}.compact
282 values = issues.collect {|i| begin; Kernel.Float(i.custom_value_for(c.custom_field).to_s); rescue; nil; end}.compact
276 assert !values.empty?
283 assert !values.empty?
277 assert_equal values.sort, values
284 assert_equal values.sort, values
278 end
285 end
279
286
280 def test_invalid_query_should_raise_query_statement_invalid_error
287 def test_invalid_query_should_raise_query_statement_invalid_error
281 q = Query.new
288 q = Query.new
282 assert_raise Query::StatementInvalid do
289 assert_raise Query::StatementInvalid do
283 q.issues(:conditions => "foo = 1")
290 q.issues(:conditions => "foo = 1")
284 end
291 end
285 end
292 end
286
293
287 def test_issue_count_by_association_group
294 def test_issue_count_by_association_group
288 q = Query.new(:name => '_', :group_by => 'assigned_to')
295 q = Query.new(:name => '_', :group_by => 'assigned_to')
289 count_by_group = q.issue_count_by_group
296 count_by_group = q.issue_count_by_group
290 assert_kind_of Hash, count_by_group
297 assert_kind_of Hash, count_by_group
291 assert_equal %w(NilClass User), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
298 assert_equal %w(NilClass User), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
292 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
299 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
293 assert count_by_group.has_key?(User.find(3))
300 assert count_by_group.has_key?(User.find(3))
294 end
301 end
295
302
296 def test_issue_count_by_list_custom_field_group
303 def test_issue_count_by_list_custom_field_group
297 q = Query.new(:name => '_', :group_by => 'cf_1')
304 q = Query.new(:name => '_', :group_by => 'cf_1')
298 count_by_group = q.issue_count_by_group
305 count_by_group = q.issue_count_by_group
299 assert_kind_of Hash, count_by_group
306 assert_kind_of Hash, count_by_group
300 assert_equal %w(NilClass String), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
307 assert_equal %w(NilClass String), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
301 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
308 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
302 assert count_by_group.has_key?('MySQL')
309 assert count_by_group.has_key?('MySQL')
303 end
310 end
304
311
305 def test_issue_count_by_date_custom_field_group
312 def test_issue_count_by_date_custom_field_group
306 q = Query.new(:name => '_', :group_by => 'cf_8')
313 q = Query.new(:name => '_', :group_by => 'cf_8')
307 count_by_group = q.issue_count_by_group
314 count_by_group = q.issue_count_by_group
308 assert_kind_of Hash, count_by_group
315 assert_kind_of Hash, count_by_group
309 assert_equal %w(Date NilClass), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
316 assert_equal %w(Date NilClass), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
310 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
317 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
311 end
318 end
312
319
313 def test_label_for
320 def test_label_for
314 q = Query.new
321 q = Query.new
315 assert_equal 'assigned_to', q.label_for('assigned_to_id')
322 assert_equal 'assigned_to', q.label_for('assigned_to_id')
316 end
323 end
317
324
318 def test_editable_by
325 def test_editable_by
319 admin = User.find(1)
326 admin = User.find(1)
320 manager = User.find(2)
327 manager = User.find(2)
321 developer = User.find(3)
328 developer = User.find(3)
322
329
323 # Public query on project 1
330 # Public query on project 1
324 q = Query.find(1)
331 q = Query.find(1)
325 assert q.editable_by?(admin)
332 assert q.editable_by?(admin)
326 assert q.editable_by?(manager)
333 assert q.editable_by?(manager)
327 assert !q.editable_by?(developer)
334 assert !q.editable_by?(developer)
328
335
329 # Private query on project 1
336 # Private query on project 1
330 q = Query.find(2)
337 q = Query.find(2)
331 assert q.editable_by?(admin)
338 assert q.editable_by?(admin)
332 assert !q.editable_by?(manager)
339 assert !q.editable_by?(manager)
333 assert q.editable_by?(developer)
340 assert q.editable_by?(developer)
334
341
335 # Private query for all projects
342 # Private query for all projects
336 q = Query.find(3)
343 q = Query.find(3)
337 assert q.editable_by?(admin)
344 assert q.editable_by?(admin)
338 assert !q.editable_by?(manager)
345 assert !q.editable_by?(manager)
339 assert q.editable_by?(developer)
346 assert q.editable_by?(developer)
340
347
341 # Public query for all projects
348 # Public query for all projects
342 q = Query.find(4)
349 q = Query.find(4)
343 assert q.editable_by?(admin)
350 assert q.editable_by?(admin)
344 assert !q.editable_by?(manager)
351 assert !q.editable_by?(manager)
345 assert !q.editable_by?(developer)
352 assert !q.editable_by?(developer)
346 end
353 end
347 end
354 end
General Comments 0
You need to be logged in to leave comments. Login now