@@ -84,6 +84,14 module QueriesHelper | |||
|
84 | 84 | tags |
|
85 | 85 | end |
|
86 | 86 | |
|
87 | def available_totalable_columns_tags(query) | |
|
88 | tags = ''.html_safe | |
|
89 | query.available_totalable_columns.each do |column| | |
|
90 | tags << content_tag('label', check_box_tag('t[]', column.name.to_s, query.totalable_columns.include?(column), :id => nil) + " #{column.caption}", :class => 'inline') | |
|
91 | end | |
|
92 | tags | |
|
93 | end | |
|
94 | ||
|
87 | 95 | def query_available_inline_columns_options(query) |
|
88 | 96 | (query.available_inline_columns - query.columns).reject(&:frozen?).collect {|column| [column.caption, column.name]} |
|
89 | 97 | end |
@@ -97,6 +105,16 module QueriesHelper | |||
|
97 | 105 | render :partial => 'queries/columns', :locals => {:query => query, :tag_name => tag_name} |
|
98 | 106 | end |
|
99 | 107 | |
|
108 | def render_query_totals(query) | |
|
109 | return unless query.totalable_columns.present? | |
|
110 | totals = query.totalable_columns.map do |column| | |
|
111 | label = content_tag('span', "#{column.caption}:") | |
|
112 | value = content_tag('span', " #{query.total_for(column)}", :class => 'value') | |
|
113 | content_tag('span', label + " " + value, :class => "total-for-#{column.name.to_s.dasherize}") | |
|
114 | end | |
|
115 | content_tag('p', totals.join(" ").html_safe, :class => "query-totals") | |
|
116 | end | |
|
117 | ||
|
100 | 118 | def column_header(column) |
|
101 | 119 | column.sortable ? sort_header_tag(column.name.to_s, :caption => column.caption, |
|
102 | 120 | :default_order => column.default_order) : |
@@ -194,12 +212,12 module QueriesHelper | |||
|
194 | 212 | @query = IssueQuery.new(:name => "_") |
|
195 | 213 | @query.project = @project |
|
196 | 214 | @query.build_from_params(params) |
|
197 | session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names} | |
|
215 | session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names, :totalable_names => @query.totalable_names} | |
|
198 | 216 | else |
|
199 | 217 | # retrieve from session |
|
200 | 218 | @query = nil |
|
201 | 219 | @query = IssueQuery.find_by_id(session[:query][:id]) if session[:query][:id] |
|
202 | @query ||= IssueQuery.new(:name => "_", :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names]) | |
|
220 | @query ||= IssueQuery.new(:name => "_", :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names], :totalable_names => session[:query][:totalable_names]) | |
|
203 | 221 | @query.project = @project |
|
204 | 222 | end |
|
205 | 223 | end |
@@ -210,7 +228,7 module QueriesHelper | |||
|
210 | 228 | @query = IssueQuery.find_by_id(session[:query][:id]) |
|
211 | 229 | return unless @query |
|
212 | 230 | else |
|
213 | @query = IssueQuery.new(:name => "_", :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names]) | |
|
231 | @query = IssueQuery.new(:name => "_", :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names], :totalable_names => session[:query][:totalable_names]) | |
|
214 | 232 | end |
|
215 | 233 | if session[:query].has_key?(:project_id) |
|
216 | 234 | @query.project_id = session[:query][:project_id] |
@@ -34,7 +34,7 class IssueQuery < Query | |||
|
34 | 34 | QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true), |
|
35 | 35 | QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"), |
|
36 | 36 | QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"), |
|
37 | QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"), | |
|
37 | QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours", :totalable => true), | |
|
38 | 38 | QueryColumn.new(:total_estimated_hours, |
|
39 | 39 | :sortable => "COALESCE((SELECT SUM(estimated_hours) FROM #{Issue.table_name} subtasks" + |
|
40 | 40 | " WHERE subtasks.root_id = #{Issue.table_name}.root_id AND subtasks.lft >= #{Issue.table_name}.lft AND subtasks.rgt <= #{Issue.table_name}.rgt), 0)", |
@@ -268,7 +268,8 class IssueQuery < Query | |||
|
268 | 268 | @available_columns.insert index, QueryColumn.new(:spent_hours, |
|
269 | 269 | :sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id), 0)", |
|
270 | 270 | :default_order => 'desc', |
|
271 | :caption => :label_spent_time | |
|
271 | :caption => :label_spent_time, | |
|
272 | :totalable => true | |
|
272 | 273 | ) |
|
273 | 274 | @available_columns.insert index+1, QueryColumn.new(:total_spent_hours, |
|
274 | 275 | :sortable => "COALESCE((SELECT SUM(hours) FROM #{TimeEntry.table_name} JOIN #{Issue.table_name} subtasks ON subtasks.id = #{TimeEntry.table_name}.issue_id" + |
@@ -299,13 +300,44 class IssueQuery < Query | |||
|
299 | 300 | end |
|
300 | 301 | end |
|
301 | 302 | |
|
303 | def base_scope | |
|
304 | Issue.visible.joins(:status, :project).where(statement) | |
|
305 | end | |
|
306 | private :base_scope | |
|
307 | ||
|
302 | 308 | # Returns the issue count |
|
303 | 309 | def issue_count |
|
304 | Issue.visible.joins(:status, :project).where(statement).count | |
|
310 | base_scope.count | |
|
305 | 311 | rescue ::ActiveRecord::StatementInvalid => e |
|
306 | 312 | raise StatementInvalid.new(e.message) |
|
307 | 313 | end |
|
308 | 314 | |
|
315 | # Returns sum of all the issue's estimated_hours | |
|
316 | def total_for_estimated_hours | |
|
317 | base_scope.sum(:estimated_hours) | |
|
318 | end | |
|
319 | ||
|
320 | # Returns sum of all the issue's time entries hours | |
|
321 | def total_for_spent_hours | |
|
322 | base_scope.joins(:time_entries).sum("#{TimeEntry.table_name}.hours") | |
|
323 | end | |
|
324 | ||
|
325 | def total_for_custom_field(custom_field) | |
|
326 | base_scope.joins(:custom_values). | |
|
327 | where(:custom_values => {:custom_field_id => custom_field.id}). | |
|
328 | where.not(:custom_values => {:value => ''}). | |
|
329 | sum("CAST(#{CustomValue.table_name}.value AS decimal(30,3))") | |
|
330 | end | |
|
331 | private :total_for_custom_field | |
|
332 | ||
|
333 | def total_for_float_custom_field(custom_field) | |
|
334 | total_for_custom_field(custom_field).to_f | |
|
335 | end | |
|
336 | ||
|
337 | def total_for_int_custom_field(custom_field) | |
|
338 | total_for_custom_field(custom_field).to_i | |
|
339 | end | |
|
340 | ||
|
309 | 341 | # Returns the issue count by group or nil if query is not grouped |
|
310 | 342 | def issue_count_by_group |
|
311 | 343 | r = nil |
@@ -16,7 +16,7 | |||
|
16 | 16 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
|
17 | 17 | |
|
18 | 18 | class QueryColumn |
|
19 | attr_accessor :name, :sortable, :groupable, :default_order | |
|
19 | attr_accessor :name, :sortable, :groupable, :totalable, :default_order | |
|
20 | 20 | include Redmine::I18n |
|
21 | 21 | |
|
22 | 22 | def initialize(name, options={}) |
@@ -26,6 +26,7 class QueryColumn | |||
|
26 | 26 | if groupable == true |
|
27 | 27 | self.groupable = name.to_s |
|
28 | 28 | end |
|
29 | self.totalable = options[:totalable] || false | |
|
29 | 30 | self.default_order = options[:default_order] |
|
30 | 31 | @inline = options.key?(:inline) ? options[:inline] : true |
|
31 | 32 | @caption_key = options[:caption] || "field_#{name}".to_sym |
@@ -79,6 +80,7 class QueryCustomFieldColumn < QueryColumn | |||
|
79 | 80 | self.name = "cf_#{custom_field.id}".to_sym |
|
80 | 81 | self.sortable = custom_field.order_statement || false |
|
81 | 82 | self.groupable = custom_field.group_statement || false |
|
83 | self.totalable = ['int', 'float'].include?(custom_field.field_format) | |
|
82 | 84 | @inline = true |
|
83 | 85 | @cf = custom_field |
|
84 | 86 | end |
@@ -246,6 +248,7 class Query < ActiveRecord::Base | |||
|
246 | 248 | end |
|
247 | 249 | self.group_by = params[:group_by] || (params[:query] && params[:query][:group_by]) |
|
248 | 250 | self.column_names = params[:c] || (params[:query] && params[:query][:column_names]) |
|
251 | self.totalable_names = params[:t] || (params[:query] && params[:query][:totalable_names]) | |
|
249 | 252 | self |
|
250 | 253 | end |
|
251 | 254 | |
@@ -454,6 +457,10 class Query < ActiveRecord::Base | |||
|
454 | 457 | available_columns.reject(&:inline?) |
|
455 | 458 | end |
|
456 | 459 | |
|
460 | def available_totalable_columns | |
|
461 | available_columns.select(&:totalable) | |
|
462 | end | |
|
463 | ||
|
457 | 464 | def default_columns_names |
|
458 | 465 | [] |
|
459 | 466 | end |
@@ -482,6 +489,22 class Query < ActiveRecord::Base | |||
|
482 | 489 | column_names.nil? || column_names.empty? |
|
483 | 490 | end |
|
484 | 491 | |
|
492 | def totalable_columns | |
|
493 | names = totalable_names | |
|
494 | available_totalable_columns.select {|column| names.include?(column.name)} | |
|
495 | end | |
|
496 | ||
|
497 | def totalable_names=(names) | |
|
498 | if names | |
|
499 | names = names.select(&:present?).map {|n| n.is_a?(Symbol) ? n : n.to_sym} | |
|
500 | end | |
|
501 | options[:totalable_names] = names | |
|
502 | end | |
|
503 | ||
|
504 | def totalable_names | |
|
505 | options[:totalable_names] || Setting.issue_list_default_totals.map(&:to_sym) || [] | |
|
506 | end | |
|
507 | ||
|
485 | 508 | def sort_criteria=(arg) |
|
486 | 509 | c = [] |
|
487 | 510 | if arg.is_a?(Hash) |
@@ -607,6 +630,22 class Query < ActiveRecord::Base | |||
|
607 | 630 | filters_clauses.any? ? filters_clauses.join(' AND ') : nil |
|
608 | 631 | end |
|
609 | 632 | |
|
633 | # Returns the sum of values for the given column | |
|
634 | def total_for(column) | |
|
635 | unless column.is_a?(QueryColumn) | |
|
636 | column = column.to_sym | |
|
637 | column = available_totalable_columns.detect {|c| c.name == column} | |
|
638 | end | |
|
639 | if column.is_a?(QueryCustomFieldColumn) | |
|
640 | custom_field = column.custom_field | |
|
641 | send "total_for_#{custom_field.field_format}_custom_field", custom_field | |
|
642 | else | |
|
643 | send "total_for_#{column.name}" | |
|
644 | end | |
|
645 | rescue ::ActiveRecord::StatementInvalid => e | |
|
646 | raise StatementInvalid.new(e.message) | |
|
647 | end | |
|
648 | ||
|
610 | 649 | private |
|
611 | 650 | |
|
612 | 651 | def sql_for_custom_field(field, operator, value, custom_field_id) |
@@ -39,6 +39,10 | |||
|
39 | 39 | <td><%= l(:button_show) %></td> |
|
40 | 40 | <td><%= available_block_columns_tags(@query) %></td> |
|
41 | 41 | </tr> |
|
42 | <tr> | |
|
43 | <td><%= l(:label_total_plural) %></td> | |
|
44 | <td><%= available_totalable_columns_tags(@query) %></td> | |
|
45 | </tr> | |
|
42 | 46 | </table> |
|
43 | 47 | </div> |
|
44 | 48 | </fieldset> |
@@ -60,6 +64,7 | |||
|
60 | 64 | <% if @issues.empty? %> |
|
61 | 65 | <p class="nodata"><%= l(:label_no_data) %></p> |
|
62 | 66 | <% else %> |
|
67 | <%= render_query_totals(@query) %> | |
|
63 | 68 | <%= render :partial => 'issues/list', :locals => {:issues => @issues, :query => @query} %> |
|
64 | 69 | <p class="pagination"><%= pagination_links_full @issue_pages, @issue_count %></p> |
|
65 | 70 | <% end %> |
@@ -33,6 +33,9 | |||
|
33 | 33 | |
|
34 | 34 | <p><label><%= l(:button_show) %></label> |
|
35 | 35 | <%= available_block_columns_tags(@query) %></p> |
|
36 | ||
|
37 | <p><label><%= l(:label_total_plural) %></label> | |
|
38 | <%= available_totalable_columns_tags(@query) %></p> | |
|
36 | 39 | </fieldset> |
|
37 | 40 | <% else %> |
|
38 | 41 | <fieldset><legend><%= l(:label_options) %></legend> |
@@ -38,6 +38,11 | |||
|
38 | 38 | <%= render_query_columns_selection( |
|
39 | 39 | IssueQuery.new(:column_names => Setting.issue_list_default_columns), |
|
40 | 40 | :name => 'settings[issue_list_default_columns]') %> |
|
41 | ||
|
42 | <p><%= setting_multiselect :issue_list_default_totals, | |
|
43 | IssueQuery.new(:totalable_names => Setting.issue_list_default_totals).available_totalable_columns.map {|c| [c.caption, c.name.to_s]}, | |
|
44 | :inline => true, | |
|
45 | :label => :label_total_plural %></p> | |
|
41 | 46 | </fieldset> |
|
42 | 47 | |
|
43 | 48 | <%= submit_tag l(:button_save) %> |
@@ -646,6 +646,7 en: | |||
|
646 | 646 | one: 1 issue |
|
647 | 647 | other: "%{count} issues" |
|
648 | 648 | label_total: Total |
|
649 | label_total_plural: Totals | |
|
649 | 650 | label_total_time: Total time |
|
650 | 651 | label_permissions: Permissions |
|
651 | 652 | label_current_status: Current status |
@@ -666,6 +666,7 fr: | |||
|
666 | 666 | one: 1 demande |
|
667 | 667 | other: "%{count} demandes" |
|
668 | 668 | label_total: Total |
|
669 | label_total_plural: Totaux | |
|
669 | 670 | label_total_time: Temps total |
|
670 | 671 | label_permissions: Permissions |
|
671 | 672 | label_current_status: Statut actuel |
@@ -180,6 +180,9 issue_list_default_columns: | |||
|
180 | 180 | - subject |
|
181 | 181 | - assigned_to |
|
182 | 182 | - updated_on |
|
183 | issue_list_default_totals: | |
|
184 | serialized: true | |
|
185 | default: [] | |
|
183 | 186 | display_subprojects_issues: |
|
184 | 187 | default: 1 |
|
185 | 188 | issue_done_ratio: |
@@ -271,6 +271,9 table.query-columns td.buttons { | |||
|
271 | 271 | text-align: center; |
|
272 | 272 | } |
|
273 | 273 | table.query-columns td.buttons input[type=button] {width:35px;} |
|
274 | .query-totals {text-align:right; margin-top:-2.3em;} | |
|
275 | .query-totals>span {margin-left:0.6em;} | |
|
276 | .query-totals .value {font-weight:bold;} | |
|
274 | 277 | |
|
275 | 278 | td.center {text-align:center;} |
|
276 | 279 |
@@ -945,6 +945,38 class IssuesControllerTest < ActionController::TestCase | |||
|
945 | 945 | assert_select 'td.parent a[title=?]', parent.subject |
|
946 | 946 | end |
|
947 | 947 | |
|
948 | def test_index_with_estimated_hours_total | |
|
949 | Issue.delete_all | |
|
950 | Issue.generate!(:estimated_hours => 5.5) | |
|
951 | Issue.generate!(:estimated_hours => 1.1) | |
|
952 | ||
|
953 | get :index, :t => %w(estimated_hours) | |
|
954 | assert_response :success | |
|
955 | assert_select '.query-totals' | |
|
956 | assert_select '.total-for-estimated-hours span.value', :text => '6.6' | |
|
957 | assert_select 'input[type=checkbox][name=?][value=estimated_hours][checked=checked]', 't[]' | |
|
958 | end | |
|
959 | ||
|
960 | def test_index_with_int_custom_field_total | |
|
961 | field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true) | |
|
962 | CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2') | |
|
963 | CustomValue.create!(:customized => Issue.find(2), :custom_field => field, :value => '7') | |
|
964 | ||
|
965 | get :index, :t => ["cf_#{field.id}"] | |
|
966 | assert_response :success | |
|
967 | assert_select '.query-totals' | |
|
968 | assert_select ".total-for-cf-#{field.id} span.value", :text => '9' | |
|
969 | end | |
|
970 | ||
|
971 | def test_index_totals_should_default_to_settings | |
|
972 | with_settings :issue_list_default_totals => ['estimated_hours'] do | |
|
973 | get :index | |
|
974 | assert_response :success | |
|
975 | assert_select '.total-for-estimated-hours span.value' | |
|
976 | assert_select '.query-totals>span', 1 | |
|
977 | end | |
|
978 | end | |
|
979 | ||
|
948 | 980 | def test_index_send_html_if_query_is_invalid |
|
949 | 981 | get :index, :f => ['start_date'], :op => {:start_date => '='} |
|
950 | 982 | assert_equal 'text/html', @response.content_type |
@@ -1136,6 +1136,82 class QueryTest < ActiveSupport::TestCase | |||
|
1136 | 1136 | assert_equal values.sort, values |
|
1137 | 1137 | end |
|
1138 | 1138 | |
|
1139 | def test_set_totalable_names | |
|
1140 | q = IssueQuery.new | |
|
1141 | q.totalable_names = ['estimated_hours', :spent_hours, ''] | |
|
1142 | assert_equal [:estimated_hours, :spent_hours], q.totalable_columns.map(&:name) | |
|
1143 | end | |
|
1144 | ||
|
1145 | def test_totalable_columns_should_default_to_settings | |
|
1146 | with_settings :issue_list_default_totals => ['estimated_hours'] do | |
|
1147 | q = IssueQuery.new | |
|
1148 | assert_equal [:estimated_hours], q.totalable_columns.map(&:name) | |
|
1149 | end | |
|
1150 | end | |
|
1151 | ||
|
1152 | def test_available_totalable_columns_should_include_estimated_hours | |
|
1153 | q = IssueQuery.new | |
|
1154 | assert_include :estimated_hours, q.available_totalable_columns.map(&:name) | |
|
1155 | end | |
|
1156 | ||
|
1157 | def test_available_totalable_columns_should_include_spent_hours | |
|
1158 | User.current = User.find(1) | |
|
1159 | ||
|
1160 | q = IssueQuery.new | |
|
1161 | assert_include :spent_hours, q.available_totalable_columns.map(&:name) | |
|
1162 | end | |
|
1163 | ||
|
1164 | def test_available_totalable_columns_should_include_int_custom_field | |
|
1165 | field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true) | |
|
1166 | q = IssueQuery.new | |
|
1167 | assert_include "cf_#{field.id}".to_sym, q.available_totalable_columns.map(&:name) | |
|
1168 | end | |
|
1169 | ||
|
1170 | def test_available_totalable_columns_should_include_float_custom_field | |
|
1171 | field = IssueCustomField.generate!(:field_format => 'float', :is_for_all => true) | |
|
1172 | q = IssueQuery.new | |
|
1173 | assert_include "cf_#{field.id}".to_sym, q.available_totalable_columns.map(&:name) | |
|
1174 | end | |
|
1175 | ||
|
1176 | def test_total_for_estimated_hours | |
|
1177 | Issue.delete_all | |
|
1178 | Issue.generate!(:estimated_hours => 5.5) | |
|
1179 | Issue.generate!(:estimated_hours => 1.1) | |
|
1180 | Issue.generate! | |
|
1181 | ||
|
1182 | q = IssueQuery.new | |
|
1183 | assert_equal 6.6, q.total_for(:estimated_hours) | |
|
1184 | end | |
|
1185 | ||
|
1186 | def test_total_for_spent_hours | |
|
1187 | TimeEntry.delete_all | |
|
1188 | TimeEntry.generate!(:hours => 5.5) | |
|
1189 | TimeEntry.generate!(:hours => 1.1) | |
|
1190 | ||
|
1191 | q = IssueQuery.new | |
|
1192 | assert_equal 6.6, q.total_for(:spent_hours) | |
|
1193 | end | |
|
1194 | ||
|
1195 | def test_total_for_int_custom_field | |
|
1196 | field = IssueCustomField.generate!(:field_format => 'int', :is_for_all => true) | |
|
1197 | CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2') | |
|
1198 | CustomValue.create!(:customized => Issue.find(2), :custom_field => field, :value => '7') | |
|
1199 | CustomValue.create!(:customized => Issue.find(3), :custom_field => field, :value => '') | |
|
1200 | ||
|
1201 | q = IssueQuery.new | |
|
1202 | assert_equal 9, q.total_for("cf_#{field.id}") | |
|
1203 | end | |
|
1204 | ||
|
1205 | def test_total_for_float_custom_field | |
|
1206 | field = IssueCustomField.generate!(:field_format => 'float', :is_for_all => true) | |
|
1207 | CustomValue.create!(:customized => Issue.find(1), :custom_field => field, :value => '2.3') | |
|
1208 | CustomValue.create!(:customized => Issue.find(2), :custom_field => field, :value => '7') | |
|
1209 | CustomValue.create!(:customized => Issue.find(3), :custom_field => field, :value => '') | |
|
1210 | ||
|
1211 | q = IssueQuery.new | |
|
1212 | assert_equal 9.3, q.total_for("cf_#{field.id}") | |
|
1213 | end | |
|
1214 | ||
|
1139 | 1215 | def test_invalid_query_should_raise_query_statement_invalid_error |
|
1140 | 1216 | q = IssueQuery.new |
|
1141 | 1217 | assert_raise Query::StatementInvalid do |
General Comments 0
You need to be logged in to leave comments.
Login now