diff --git a/app/models/issue_query.rb b/app/models/issue_query.rb index f44e5e9..7ede81f 100644 --- a/app/models/issue_query.rb +++ b/app/models/issue_query.rb @@ -17,6 +17,55 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +class QueryAssignedToGroupColumn < QueryColumn + include Redmine::I18n + + def initialize + super(:assigned_to_group, :caption => :field_member_of_group) + end + + def groupable? + true + end + + def sortable + group_by_statement + end + + def group_by_statement + gu_table = "#{User.table_name_prefix}groups_users#{User.table_name_suffix}" + "COALESCE(" \ + "(SELECT MIN(gu.group_id) FROM #{gu_table} gu" \ + " WHERE gu.user_id = #{Issue.table_name}.assigned_to_id)," \ + "(SELECT g.id FROM #{User.table_name} g" \ + " WHERE g.id = #{Issue.table_name}.assigned_to_id AND g.type = 'Group')" \ + ")" + end + + def group_value(issue) + return nil if issue.assigned_to.nil? + + if issue.assigned_to.is_a?(Group) + issue.assigned_to + else + # order(:id) matches MIN(group_id) used in group_by_statement + issue.assigned_to.groups.givable.order(:id).first + end + end + + def value(issue) + group_value(issue) + end + + def cast_group_keys(result) + group_ids = result.keys.compact + return result if group_ids.empty? + + groups = Group.where(:id => group_ids).index_by(&:id) + result.transform_keys {|k| k.nil? ? nil : groups[k.to_i]} + end +end + class IssueQuery < Query self.queried_class = Issue self.view_permission = :view_issues @@ -41,6 +90,7 @@ class IssueQuery < Query QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true), + QueryAssignedToGroupColumn.new, WatcherQueryColumn.new(:watcher_users, :caption => :label_issue_watchers), TimestampQueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc', :groupable => true), diff --git a/app/models/query.rb b/app/models/query.rb index fc9b62b..0921b50 100644 --- a/app/models/query.rb +++ b/app/models/query.rb @@ -1117,6 +1117,8 @@ class Query < ApplicationRecord c = group_by_column if c.is_a?(QueryCustomFieldColumn) r = r.keys.to_h { |k| [c.custom_field.cast_value(k), r[k]] } + elsif c.respond_to?(:cast_group_keys) + r = c.cast_group_keys(r) end end r diff --git a/test/unit/query_test.rb b/test/unit/query_test.rb index 3f1e2dd..42b2707 100644 --- a/test/unit/query_test.rb +++ b/test/unit/query_test.rb @@ -3487,4 +3487,173 @@ class QueryTest < ActiveSupport::TestCase assigned_to_values = query.assigned_to_values assert_equal expected_names, assigned_to_values[1..].map(&:first) end + + # Tests for QueryAssignedToGroupColumn / group-by "Assignee's group" (issue #7655) + def setup_assigned_to_group + Group.destroy_all + @user_in_group_a = User.generate! + @user_in_group_b = User.generate! + @user_in_both = User.generate! # belongs to both groups + @user_no_group = User.generate! + + @group_a = Group.generate!.reload + @group_a.users << @user_in_group_a + @group_a.users << @user_in_both + + @group_b = Group.generate!.reload + @group_b.users << @user_in_group_b + @group_b.users << @user_in_both + + Issue.delete_all + @issue_group_a = Issue.generate!(:assigned_to => @user_in_group_a) + @issue_group_b = Issue.generate!(:assigned_to => @user_in_group_b) + @issue_both_groups = Issue.generate!(:assigned_to => @user_in_both) + @issue_no_group = Issue.generate!(:assigned_to => @user_no_group) + @issue_unassigned = Issue.generate! + @issue_direct_grp = Issue.generate!(:assigned_to => @group_a) + end + + test "assigned_to_group column should be included in groupable_columns" do + q = IssueQuery.new + col = q.groupable_columns.detect {|c| c.name == :assigned_to_group} + assert_not_nil col, "assigned_to_group should be in groupable_columns" + assert_kind_of QueryAssignedToGroupColumn, col + end + + test "grouped? should be true when group_by is assigned_to_group" do + q = IssueQuery.new(:group_by => 'assigned_to_group') + assert q.grouped? + assert_not_nil q.group_by_column + assert_equal :assigned_to_group, q.group_by_column.name + assert_not_nil q.group_by_statement + end + + test "group_by_statement should be a valid SQL expression" do + q = IssueQuery.new(:group_by => 'assigned_to_group') + stmt = q.group_by_statement + assert_match(/COALESCE/i, stmt) + assert_match(/groups_users/, stmt) + end + + test "result_count_by_group should return Group objects as keys" do + setup_assigned_to_group + q = IssueQuery.new(:name => '_', :group_by => 'assigned_to_group') + + counts = q.result_count_by_group + assert_not_nil counts + + counts.each_key do |k| + assert(k.nil? || k.is_a?(Group), + "Expected nil or Group key, got #{k.class}: #{k.inspect}") + end + end + + test "result_count_by_group correctly counts issues per group" do + setup_assigned_to_group + q = IssueQuery.new(:name => '_', :group_by => 'assigned_to_group') + counts = q.result_count_by_group + + # nil bucket: @issue_no_group + @issue_unassigned + assert_equal 2, counts[nil] + # 4 issues are assigned to grouped users/groups; split between group_a and group_b + assert_equal 4, (counts[@group_a] || 0) + (counts[@group_b] || 0) + # @issue_group_a (user only in A) + @issue_direct_grp (A directly) are always in group_a + assert (counts[@group_a] || 0) >= 2, "group_a must include at least issue_group_a and issue_direct_grp" + # @issue_group_b (user only in B) is always in group_b + assert (counts[@group_b] || 0) >= 1, "group_b must include at least issue_group_b" + # @issue_both_groups goes to whichever group has the lower id (MIN) + # — exact split depends on auto-increment ordering so we only verify the sum + end + + test "group_value should return the Group for a user-assigned issue" do + setup_assigned_to_group + col = QueryAssignedToGroupColumn.new + result = col.group_value(@issue_group_a) + assert_kind_of Group, result + assert_equal @group_a, result + end + + test "group_value should return the Group itself for a directly-group-assigned issue" do + setup_assigned_to_group + col = QueryAssignedToGroupColumn.new + assert_equal @group_a, col.group_value(@issue_direct_grp) + end + + test "group_value should return nil for unassigned issue" do + setup_assigned_to_group + col = QueryAssignedToGroupColumn.new + assert_nil col.group_value(@issue_unassigned) + end + + test "group_value should return nil for user not in any group" do + setup_assigned_to_group + col = QueryAssignedToGroupColumn.new + assert_nil col.group_value(@issue_no_group) + end + + test "issues method should not raise when grouped by assigned_to_group" do + setup_assigned_to_group + q = IssueQuery.new(:name => '_', :group_by => 'assigned_to_group') + assert_nothing_raised { q.issues } + end + + test "issues should be ordered so same-group issues are adjacent" do + setup_assigned_to_group + q = IssueQuery.new(:name => '_', :group_by => 'assigned_to_group') + col = q.group_by_column + + issues = q.issues + groups = issues.map {|i| col.group_value(i)} + + seen = [] + groups.each do |g| + if seen.empty? || seen.last != g + assert_not_include seen[0..-2], g, + "Group #{g.inspect} appeared non-adjacently in the ordered issue list" + seen << g + end + end + end + + test "total_by_group_for should work with assigned_to_group" do + setup_assigned_to_group + @issue_group_a.update_column(:estimated_hours, 3.0) + @issue_group_b.update_column(:estimated_hours, 5.0) + + q = IssueQuery.new(:name => '_', :group_by => 'assigned_to_group') + totals = q.total_by_group_for(:estimated_hours) + + assert_not_nil totals + totals.each_key do |k| + assert(k.nil? || k.is_a?(Group), + "total_by_group_for keys must be nil or Group, got #{k.class}") + end + end + + test "QueryAssignedToGroupColumn cast_group_keys converts integer keys to Group objects" do + setup_assigned_to_group + col = QueryAssignedToGroupColumn.new + + raw = {@group_a.id => 3, @group_b.id => 2, nil => 1} + converted = col.cast_group_keys(raw) + + assert_equal 3, converted[@group_a] + assert_equal 2, converted[@group_b] + assert_equal 1, converted[nil] + converted.each_key do |k| + assert(k.nil? || k.is_a?(Group), + "cast_group_keys should not leave integer keys, got #{k.class}") + end + end + + test "QueryAssignedToGroupColumn cast_group_keys handles empty result" do + col = QueryAssignedToGroupColumn.new + assert_equal({}, col.cast_group_keys({})) + end + + test "QueryAssignedToGroupColumn cast_group_keys handles all-nil keys" do + col = QueryAssignedToGroupColumn.new + result = col.cast_group_keys({nil => 5}) + assert_equal({nil => 5}, result) + end end